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,ignore
9//! use bamboo_agent::agent::Agent;
10//!
11//! let agent = Agent::builder()
12//!     .model("claude-sonnet-4-6")
13//!     .instruction("You help users research topics thoroughly.")
14//!     .with_defaults_for_data_dir(data_dir).await?
15//!     .build()?;
16//!
17//! let mut session = Session::new("s1", "claude-sonnet-4-6");
18//! agent.run(&mut session, "investigate X").await?;
19//! ```
20//!
21//! ## Surface
22//!
23//! - [`Agent`] — stable entry point wrapping the engine runtime. `run` /
24//!   `run_stream` execute the agent loop with the configured instruction +
25//!   tool policy + model applied to the session.
26//! - [`AgentBuilder`] — concise builder (`.model()`, `.instruction()`,
27//!   `.tools()`) that assembles default deps via
28//!   [`AgentBuilder::with_defaults_for_data_dir`].
29//! - [`ExecuteRequestBuilder`] — ergonomic builder over the multi-field
30//!   [`bamboo_engine::ExecuteRequest`].
31//! - [`ToolSpec`] + [`builtin_tool_names`] — tool
32//!   descriptors derived from the canonical `BUILTIN_TOOL_NAMES`.
33//!
34//! ## Anti-fork invariant
35//!
36//! The SDK never reimplements the agent loop. `run` / `run_stream` funnel into
37//! `bamboo_engine::Agent::execute` (the single canonical execution path).
38
39mod builder;
40mod error;
41mod execute_request;
42mod tools;
43
44use std::sync::Arc;
45
46use async_trait::async_trait;
47pub use builder::AgentBuilder;
48pub use execute_request::ExecuteRequestBuilder;
49use tokio::sync::mpsc;
50
51use bamboo_engine::session_app::errors::{SessionLoadError, SessionSaveError};
52use bamboo_engine::session_app::repository::SessionAccess;
53use bamboo_engine::session_app::respond::{
54    submit_pending_response, PERMISSION_REEXECUTE_METADATA_KEY,
55};
56use bamboo_engine::session_app::types::RespondInput;
57
58// Re-exported so callers can name the token returned by the `*_cancellable` /
59// `*_with_cancel` run helpers without depending on `tokio-util` directly.
60pub use tokio_util::sync::CancellationToken;
61pub use tools::{
62    builtin_tool_names, builtin_tool_specs, BuiltinTool, ToolSpec, CANONICAL_TOOL_NAMES,
63};
64
65pub use error::SdkError;
66
67// Convenience re-exports of commonly used types (single source of truth — these
68// supersede the old duplicate re-export chain, resolving TD-2).
69pub use bamboo_agent_core::{
70    AgentError, AgentEvent, Message, MessageContent, PendingQuestion, Role, Session,
71    TokenBudgetUsage, TokenUsage,
72};
73pub use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
74pub use bamboo_engine::session_app::respond::PlanModeTransition;
75pub use bamboo_engine::ExecuteRequest;
76pub use bamboo_llm::LLMProvider;
77pub use bamboo_mcp::manager::McpServerManager;
78pub use bamboo_mcp::McpServerConfig;
79pub use bamboo_storage::SessionIndexEntry;
80pub use bamboo_tools::permission::{PermissionChecker, PermissionType};
81pub use bamboo_tools::{BuiltinToolExecutor, BuiltinToolExecutorBuilder, ToolOutputManager};
82
83/// Default event-channel buffer used by [`Agent::run`].
84const EVENT_CHANNEL_CAPACITY: usize = 256;
85
86/// Stable, ergonomic entry point for agent execution.
87///
88/// Wraps a [`bamboo_engine::Agent`] (which owns the shared runtime) plus the
89/// instruction / tool policy / model configured at build time. Clone is cheap.
90#[derive(Clone)]
91pub struct Agent {
92    inner: bamboo_engine::Agent,
93    /// Instruction (system-prompt fragment) injected into the session at `run`
94    /// time; the engine assembles the full prompt around it.
95    system_prompt: Option<String>,
96    /// Model override applied to the session at `run` time.
97    model: Option<String>,
98    /// Concrete session-index handle, present only when assembled via
99    /// [`AgentBuilder::with_defaults_for_data_dir`]. Backs
100    /// [`list_sessions`](Self::list_sessions) — the type-erased
101    /// `Arc<dyn Storage>` the engine builder takes can't list.
102    session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
103    /// Permission checker configured via
104    /// [`AgentBuilder::permission_checker`], if any. Used by
105    /// [`answer`](Self::answer) to apply permission grants implied by an
106    /// approved permission prompt, mirroring what the HTTP `/respond` handler
107    /// does for `state.permission_checker`.
108    permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
109}
110
111impl Agent {
112    /// Return a new ergonomic builder.
113    pub fn builder() -> AgentBuilder {
114        AgentBuilder::new()
115    }
116
117    /// Wrap an existing engine [`Agent`](bamboo_engine::Agent) with no extra
118    /// role configuration.
119    pub fn from_runtime(inner: bamboo_engine::Agent) -> Self {
120        Self {
121            inner,
122            system_prompt: None,
123            model: None,
124            session_store: None,
125            permission_checker: None,
126        }
127    }
128
129    /// Wrap an engine [`Agent`](bamboo_engine::Agent) plus the instruction /
130    /// model configuration assembled by [`AgentBuilder`].
131    pub(crate) fn from_runtime_with_config(
132        inner: bamboo_engine::Agent,
133        system_prompt: Option<String>,
134        model: Option<String>,
135        session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
136        permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
137    ) -> Self {
138        Self {
139            inner,
140            system_prompt,
141            model,
142            session_store,
143            permission_checker,
144        }
145    }
146
147    /// Run the agent loop on `session` with the given input, draining events
148    /// internally until completion.
149    ///
150    /// The configured instruction + model are applied to the session before
151    /// execution; the tool set was fixed on the agent's executor at build time.
152    ///
153    /// NOTE: this variant **discards every [`AgentEvent`]** (tool calls, tokens,
154    /// intermediate errors) — you only get the final `Result`. To observe the
155    /// run, use [`run_stream`](Self::run_stream) instead. To cancel a blocking
156    /// run from another task, use [`run_with_cancel`](Self::run_with_cancel).
157    pub async fn run(
158        &self,
159        session: &mut Session,
160        input: impl Into<String>,
161    ) -> Result<(), AgentError> {
162        session.add_message(Message::user(input.into()));
163        self.run_session(session).await
164    }
165
166    /// Like [`run`](Self::run) but driven by a caller-owned
167    /// [`CancellationToken`]: cancelling the token from another task stops the
168    /// loop at the next check point. Events are still discarded (see `run`).
169    pub async fn run_with_cancel(
170        &self,
171        session: &mut Session,
172        input: impl Into<String>,
173        cancel_token: CancellationToken,
174    ) -> Result<(), AgentError> {
175        session.add_message(Message::user(input.into()));
176        self.run_session_with_cancel(session, cancel_token).await
177    }
178
179    /// Run the agent loop on `session` exactly as it stands — i.e. on a
180    /// caller-provided message list — without appending a new turn. The last
181    /// `User` message already in the session drives execution.
182    ///
183    /// This is how you pass a full conversation / message list: build the
184    /// session from your messages, then run it.
185    ///
186    /// ```rust,ignore
187    /// let mut session = Session::new("s1", "claude-sonnet-4-6");
188    /// session.add_message(Message::user("hi"));
189    /// session.add_message(Message::assistant("hello!", None));
190    /// session.add_message(Message::user("now summarize our chat"));
191    /// agent.run_session(&mut session).await?; // no extra input appended
192    /// ```
193    pub async fn run_session(&self, session: &mut Session) -> Result<(), AgentError> {
194        self.run_session_with_cancel(session, CancellationToken::new())
195            .await
196    }
197
198    /// Like [`run_session`](Self::run_session) but driven by a caller-owned
199    /// [`CancellationToken`], so a blocking run can be cancelled from another
200    /// task. Events are still discarded (see [`run`](Self::run)).
201    pub async fn run_session_with_cancel(
202        &self,
203        session: &mut Session,
204        cancel_token: CancellationToken,
205    ) -> Result<(), AgentError> {
206        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
207
208        // Drain events so the bounded channel never blocks the loop.
209        let drain = tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
210
211        let result = self.execute_internal(session, event_tx, cancel_token).await;
212
213        // Stop draining once execution returns. Detached engine tasks (e.g.
214        // background evaluations) may still hold a cloned sender, so awaiting
215        // natural channel closure could hang; abort instead.
216        drain.abort();
217        result
218    }
219
220    /// Append `input` as a new user turn, then stream the run's
221    /// [`AgentEvent`]s. The execution runs on a background task; the caller
222    /// drives it by reading from the returned receiver until it closes.
223    pub fn run_stream(
224        &self,
225        mut session: Session,
226        input: impl Into<String>,
227    ) -> mpsc::Receiver<AgentEvent> {
228        session.add_message(Message::user(input.into()));
229        self.run_stream_session(session)
230    }
231
232    /// Like [`run_stream`](Self::run_stream), but also returns a
233    /// [`CancellationToken`] for the run: call `token.cancel()` to stop the loop
234    /// at the next check point. Dropping the receiver does NOT cancel the run, so
235    /// this is the way to interrupt a streaming agent.
236    pub fn run_stream_cancellable(
237        &self,
238        mut session: Session,
239        input: impl Into<String>,
240    ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
241        session.add_message(Message::user(input.into()));
242        self.run_stream_session_cancellable(session)
243    }
244
245    /// Stream the run's [`AgentEvent`]s for a caller-provided message list,
246    /// without appending a new turn (the last `User` message drives execution).
247    pub fn run_stream_session(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
248        self.run_stream_session_with_cancel(session, CancellationToken::new())
249    }
250
251    /// Like [`run_stream_session`](Self::run_stream_session) but also returns the
252    /// run's [`CancellationToken`] so the caller can interrupt it.
253    pub fn run_stream_session_cancellable(
254        &self,
255        session: Session,
256    ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
257        let cancel_token = CancellationToken::new();
258        let rx = self.run_stream_session_with_cancel(session, cancel_token.clone());
259        (rx, cancel_token)
260    }
261
262    /// Stream a caller-provided message list under a caller-owned
263    /// [`CancellationToken`]. The shared entry point the other `run_stream*`
264    /// helpers funnel into.
265    pub fn run_stream_session_with_cancel(
266        &self,
267        mut session: Session,
268        cancel_token: CancellationToken,
269    ) -> mpsc::Receiver<AgentEvent> {
270        let (event_tx, event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
271        let agent = self.clone();
272
273        tokio::spawn(async move {
274            if let Err(error) = agent
275                .execute_internal(&mut session, event_tx, cancel_token)
276                .await
277            {
278                tracing::warn!("Agent::run_stream execution failed: {error}");
279            }
280        });
281
282        event_rx
283    }
284
285    /// Escape hatch for full per-request control: run a fully-specified
286    /// [`ExecuteRequest`] (split fast/background/summarization models, provider
287    /// handle, skill selection, custom event channel, cancellation token, …) on
288    /// `session` via the single canonical engine execution path — the same path
289    /// [`run`](Self::run) / [`run_stream`](Self::run_stream) funnel into.
290    ///
291    /// Unlike `run`/`run_stream`, this does NOT apply the builder's configured
292    /// instruction or model: the caller owns the request entirely. Build it with
293    /// [`ExecuteRequestBuilder`].
294    ///
295    /// ```rust,ignore
296    /// let (tx, _rx) = tokio::sync::mpsc::channel(256);
297    /// let req = ExecuteRequestBuilder::new("investigate X", tx, Default::default())
298    ///     .model("claude-sonnet-4-6")
299    ///     .build();
300    /// agent.execute(&mut session, req).await?;
301    /// ```
302    pub async fn execute(
303        &self,
304        session: &mut Session,
305        request: ExecuteRequest,
306    ) -> Result<(), AgentError> {
307        self.inner.execute(session, request).await
308    }
309
310    /// Shared execution path: prepare the session (system prompt + model), build
311    /// the [`ExecuteRequest`], and delegate to the canonical engine execution
312    /// path. Tool restriction is applied via the agent's executor (built time).
313    async fn execute_internal(
314        &self,
315        session: &mut Session,
316        event_tx: mpsc::Sender<AgentEvent>,
317        cancel_token: CancellationToken,
318    ) -> Result<(), AgentError> {
319        // If `answer()` just approved a gated tool call, `session.metadata` carries
320        // the re-execution marker `submit_pending_response` set — the gated tool
321        // never actually ran (the permission gate intercepted it before
322        // execution), so re-run it now for real and write the genuine output back
323        // before the loop resumes. No-op when the marker is absent (the common,
324        // non-permission path), so this is safe to run unconditionally on every
325        // entry into the loop, not just `resume`. See
326        // `reexecute_approved_tool_if_pending` for the full rationale.
327        self.reexecute_approved_tool_if_pending(session, &event_tx)
328            .await;
329
330        // Apply the instruction as the session's leading System message and set
331        // the configured model via the single authoritative pre-execution
332        // mutation point. The builder's prompt is AUTHORITATIVE: it replaces a
333        // leading System message, otherwise inserts one at index 0, so a
334        // caller-supplied session can't silently shadow the configured
335        // instruction.
336        bamboo_engine::session_app::execution_prep::prepare_session_for_execution(
337            session,
338            self.system_prompt.as_deref(),
339            self.model.as_deref(),
340        );
341
342        // The last user message in the session drives execution (the engine
343        // skips echoing `initial_message`, so we surface it for logging only).
344        let initial_message = session
345            .messages
346            .iter()
347            .rev()
348            .find(|m| matches!(m.role, Role::User))
349            .map(|m| m.content.clone())
350            .unwrap_or_default();
351
352        // Tool restriction is handled at build time: the agent's executor is
353        // built from exactly the configured tool set, so no per-run
354        // `disabled_tools` filter is needed here.
355        let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token);
356        if let Some(model) = self.model.clone() {
357            builder = builder.model(model);
358        }
359
360        self.inner.execute(session, builder.build()).await
361    }
362
363    /// Port of `bamboo-server`'s `resume_adapter.rs` re-execution logic: after
364    /// [`answer`](Self::answer) approves a permission prompt,
365    /// `submit_pending_response` stamps `session.metadata` with
366    /// [`PERMISSION_REEXECUTE_METADATA_KEY`] (the approved tool call's id) — the
367    /// gated tool was intercepted BEFORE it ran, so its recorded result is only
368    /// the synthetic "Selected response: Approve" placeholder. This re-runs the
369    /// original tool call for real, against the SAME executor the loop itself
370    /// uses ([`bamboo_engine::Agent::default_tools`]), and overwrites the
371    /// placeholder tool-result message with the genuine output — so the resumed
372    /// loop sees what the operation actually did instead of inferring it.
373    ///
374    /// Emits the same `ToolStart`/`ToolComplete` (or `ToolError`) lifecycle
375    /// events onto `event_tx` that a normal dispatch would, so a streaming
376    /// consumer sees the re-run tool card update exactly like the HTTP surface
377    /// does. Best-effort persists the updated session via
378    /// [`persistence`](Self::persistence) so the real output survives even if the
379    /// process stops before the loop's own next save — logged, not propagated,
380    /// since the loop's subsequent save will also capture it.
381    ///
382    /// No-op (returns immediately) when the marker is absent, so it is safe to
383    /// call unconditionally at the top of every execution, not just resumes.
384    async fn reexecute_approved_tool_if_pending(
385        &self,
386        session: &mut Session,
387        event_tx: &mpsc::Sender<AgentEvent>,
388    ) {
389        let Some(tool_call_id) = session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY) else {
390            return;
391        };
392
393        let Some(tool_call) = find_pending_tool_call(session, &tool_call_id) else {
394            tracing::warn!(
395                session_id = %session.id,
396                tool_call_id = %tool_call_id,
397                "Permission re-exec marker set but tool call not found in history"
398            );
399            return;
400        };
401
402        let executor = self.inner.default_tools();
403        let tool_name = tool_call.function.name.clone();
404        let is_mutating = bamboo_tools::orchestrator::classify_tool(&tool_name)
405            == bamboo_tools::orchestrator::ToolMutability::Mutating;
406
407        // Frame the re-run with the same lifecycle events the normal loop emits
408        // (via ToolEmitter) so a streaming consumer's tool card updates
409        // (running -> finished) and ToolComplete carries the REAL output — raw
410        // `execute_with_context` only streams tool tokens, not lifecycle.
411        let mut emitter = bamboo_tools::ToolEmitter::new(&tool_call.id, &tool_name, is_mutating);
412        emitter.set_auto_approved(true);
413        let _ = event_tx
414            .send(emitter.begin().clone().into_agent_event())
415            .await;
416
417        let exec_result = {
418            let ctx = bamboo_agent_core::tools::ToolExecutionContext {
419                session_id: Some(session.id.as_str()),
420                tool_call_id: tool_call_id.as_str(),
421                event_tx: Some(event_tx),
422                available_tool_schemas: None,
423                bypass_permissions: false,
424                can_async_resume: false,
425                bash_completion_sink: None,
426                pre_parsed_args: None,
427            };
428            executor.execute_with_context(&tool_call, ctx).await
429        };
430
431        let (content, success) = match exec_result {
432            Ok(tool_result) => {
433                let _ = event_tx
434                    .send(
435                        emitter
436                            .finish(Some("Re-executed after approval".to_string()))
437                            .clone()
438                            .into_agent_event(),
439                    )
440                    .await;
441                let _ = event_tx
442                    .send(AgentEvent::ToolComplete {
443                        tool_call_id: tool_call.id.clone(),
444                        result: tool_result.clone(),
445                    })
446                    .await;
447                (tool_result.result, tool_result.success)
448            }
449            Err(error) => {
450                let message = format!("Tool re-execution after approval failed: {error}");
451                let _ = event_tx
452                    .send(emitter.error(message.clone()).clone().into_agent_event())
453                    .await;
454                (message, false)
455            }
456        };
457
458        tracing::info!(
459            session_id = %session.id,
460            tool_name = %tool_name,
461            tool_call_id = %tool_call_id,
462            success,
463            "Re-executed approved tool after permission grant"
464        );
465        apply_tool_result(session, &tool_call_id, content, success);
466
467        if let Err(error) = self.persistence().save_runtime_session(session).await {
468            tracing::warn!(
469                session_id = %session.id,
470                %error,
471                "Failed to persist session after tool re-execution (loop's own save will retry)"
472            );
473        }
474    }
475
476    /// Access the shared storage backend.
477    pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
478        self.inner.storage()
479    }
480
481    /// Access the runtime persistence adapter.
482    pub fn persistence(&self) -> &Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
483        self.inner.persistence()
484    }
485
486    // ------------------------------------------------------------------
487    // Permission / approval + resume
488    // ------------------------------------------------------------------
489
490    /// Answer a suspended session's pending question — a
491    /// `conclusion_with_options` clarification OR a permission-approval
492    /// prompt (`NeedClarification` / `ToolApprovalRequested` events; both
493    /// suspend via the same `session.pending_question` mechanism, per
494    /// `bamboo_engine::session_app::respond`). This is the in-process
495    /// equivalent of the HTTP `POST /api/v1/sessions/{id}/respond` endpoint —
496    /// same use case function (`submit_pending_response`), so behavior
497    /// (validation, plan-mode transitions, permission-grant extraction)
498    /// matches exactly.
499    ///
500    /// `response` must be one of the pending question's `options` unless it
501    /// `allow_custom`s a free-form answer (returns
502    /// [`SdkError::InvalidResponse`] otherwise). Loads the session by ID from
503    /// [`storage`](Self::storage), so the run must have already suspended
504    /// (and thus persisted) before calling this — the session is NOT taken
505    /// from an in-memory handle.
506    ///
507    /// If this `Agent` was built with
508    /// [`AgentBuilder::permission_checker`](AgentBuilder::permission_checker),
509    /// any permission grants implied by an approved permission prompt are
510    /// applied to it automatically (mirroring what the HTTP handler does for
511    /// `state.permission_checker`), so the resumed re-attempt of the gated
512    /// operation passes the checker without prompting again.
513    ///
514    /// After answering, resume execution with [`resume`](Self::resume) /
515    /// [`resume_stream`](Self::resume_stream) on the returned
516    /// [`AnswerOutcome::session`] — or use
517    /// [`answer_and_resume_stream`](Self::answer_and_resume_stream) to do both
518    /// in one call.
519    ///
520    /// Like the HTTP server's `/respond` handler, approving a gated tool call
521    /// here also re-executes it for real once the run resumes: `answer` (via
522    /// `submit_pending_response`) stamps the returned session's metadata with
523    /// `PERMISSION_REEXECUTE_METADATA_KEY`, and the next call into
524    /// [`resume`](Self::resume)/[`resume_stream`](Self::resume_stream)/`run*`
525    /// re-runs the originally-gated tool call against the agent's tool executor
526    /// and overwrites the synthetic "Selected response: Approve" placeholder
527    /// with the operation's genuine output before the loop continues — see
528    /// `reexecute_approved_tool_if_pending`.
529    ///
530    /// NOTE: `ChildApprovalRequested` (an out-of-process sub-agent worker's
531    /// gated tool, proxied over the actor protocol) is a SEPARATE mechanism
532    /// from `pending_question`/`respond` and is not covered by this method —
533    /// use [`answer_child_approval`](Self::answer_child_approval) instead.
534    pub async fn answer(
535        &self,
536        session_id: impl Into<String>,
537        response: impl Into<String>,
538    ) -> Result<AnswerOutcome, SdkError> {
539        let input = RespondInput {
540            session_id: session_id.into(),
541            user_response: response.into(),
542            model: None,
543            model_ref: None,
544            provider: None,
545            reasoning_effort: None,
546        };
547        let (session, response, plan_mode_transition, permission_grants) =
548            submit_pending_response(self, input).await?;
549
550        if let Some(checker) = &self.permission_checker {
551            if let Some(request_id) = session.metadata.get(PERMISSION_REEXECUTE_METADATA_KEY) {
552                for (perm_type, resource) in &permission_grants {
553                    checker.grant_once(&session.id, request_id, *perm_type, resource.clone());
554                }
555            }
556        }
557
558        Ok(AnswerOutcome {
559            session,
560            response,
561            plan_mode_transition,
562            permission_grants,
563        })
564    }
565
566    /// Resume execution on `session` — i.e. continue the agent loop from its
567    /// current state (e.g. the tool result [`answer`](Self::answer) just
568    /// appended) WITHOUT appending a new user turn, draining events
569    /// internally until completion. An alias for
570    /// [`run_session`](Self::run_session) that documents intent at the call
571    /// site: the engine's execution entry point always resumes from whatever
572    /// is already in `session.messages` (`run`/`run_stream` are the ones that
573    /// append a fresh turn first).
574    pub async fn resume(&self, session: &mut Session) -> Result<(), AgentError> {
575        self.run_session(session).await
576    }
577
578    /// Like [`resume`](Self::resume), but driven by a caller-owned
579    /// [`CancellationToken`].
580    pub async fn resume_with_cancel(
581        &self,
582        session: &mut Session,
583        cancel_token: CancellationToken,
584    ) -> Result<(), AgentError> {
585        self.run_session_with_cancel(session, cancel_token).await
586    }
587
588    /// Like [`resume`](Self::resume), but streams [`AgentEvent`]s instead of
589    /// draining them. An alias for
590    /// [`run_stream_session`](Self::run_stream_session).
591    pub fn resume_stream(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
592        self.run_stream_session(session)
593    }
594
595    /// Like [`resume_stream`](Self::resume_stream), but also returns a
596    /// [`CancellationToken`] for the resumed run.
597    pub fn resume_stream_cancellable(
598        &self,
599        session: Session,
600    ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
601        self.run_stream_session_cancellable(session)
602    }
603
604    /// Convenience: [`answer`](Self::answer) a pending question, then
605    /// immediately [`resume_stream`](Self::resume_stream) on the resulting
606    /// session — the common "ask → answer → resume" flow in one call.
607    pub async fn answer_and_resume_stream(
608        &self,
609        session_id: impl Into<String>,
610        response: impl Into<String>,
611    ) -> Result<mpsc::Receiver<AgentEvent>, SdkError> {
612        let outcome = self.answer(session_id, response).await?;
613        Ok(self.resume_stream(outcome.session))
614    }
615
616    /// Answer an out-of-process child sub-agent's gated-tool approval request
617    /// (an [`AgentEvent::ChildApprovalRequested`] surfaced on a run's event
618    /// stream — e.g. from [`run_stream`](Self::run_stream)/
619    /// [`resume_stream`](Self::resume_stream)) — the in-process equivalent of
620    /// the HTTP `POST /api/v1/child-approval/{child_session_id}` endpoint (see
621    /// `bamboo_server::handlers::agent::child_approval`).
622    ///
623    /// This is a SEPARATE mechanism from [`answer`](Self::answer)/
624    /// [`pending_question`](Session::pending_question): a child sub-agent
625    /// worker running out-of-process (over the actor protocol, e.g. a broker
626    /// worker) that hits a gated tool escalates the approval request UP to this
627    /// process rather than suspending its own `pending_question`, and the
628    /// engine tracks it in a process-global pending-approval registry
629    /// (`bamboo_engine::external_agents::live`) keyed by `(child_session_id,
630    /// request_id)` — both taken verbatim from the surfaced event. There is no
631    /// session to load/save here: this only delivers the decision over the
632    /// child's live connection (or fails it closed if the child already
633    /// disconnected or the request already resolved/timed out).
634    ///
635    /// Returns `true` if the decision was delivered to a genuinely-pending
636    /// request, `false` if `request_id` is unknown, was already answered, timed
637    /// out, or the child is no longer live — mirroring the HTTP handler's
638    /// 200-vs-404 distinction. A `false` result does not need cleanup on the
639    /// caller's part: the request is either already resolved or has moved on.
640    ///
641    /// # Boundary
642    ///
643    /// This method only covers the TOP-orchestrator, human-in-the-loop leg of
644    /// child approval (the leg that surfaces `ChildApprovalRequested` at all).
645    /// It requires the agent to actually be driving an out-of-process child —
646    /// i.e. the caller has wired the engine's `external_agents` actor transport
647    /// (broker/worker) — which `AgentBuilder::with_defaults_for_data_dir` does
648    /// NOT assemble; that machinery is a separate, opt-in subsystem. Calling
649    /// this without a live child matching `child_session_id`/`request_id`
650    /// simply returns `false` (no panic, no error) — the same as an unmatched
651    /// HTTP POST.
652    pub fn answer_child_approval(
653        &self,
654        child_session_id: impl AsRef<str>,
655        request_id: impl AsRef<str>,
656        approved: bool,
657    ) -> bool {
658        bamboo_engine::external_agents::live::deliver_approval_checked(
659            None,
660            child_session_id.as_ref(),
661            request_id.as_ref(),
662            approved,
663        )
664    }
665
666    // ------------------------------------------------------------------
667    // Session ergonomics
668    // ------------------------------------------------------------------
669
670    /// List every session in the data directory, most-recently-updated first.
671    ///
672    /// Only available when this `Agent` was built via
673    /// [`AgentBuilder::with_defaults_for_data_dir`] (which assembles the
674    /// concrete session-index handle this needs) — returns
675    /// [`SdkError::Unsupported`] otherwise.
676    pub async fn list_sessions(&self) -> Result<Vec<bamboo_storage::SessionIndexEntry>, SdkError> {
677        let store = self.session_store.as_ref().ok_or_else(|| {
678            SdkError::Unsupported(
679                "list_sessions requires an Agent built via with_defaults_for_data_dir".to_string(),
680            )
681        })?;
682        Ok(store.list_index_entries().await)
683    }
684
685    /// Load a session by ID (its full message history + runtime state), or
686    /// `Ok(None)` if it doesn't exist.
687    pub async fn get_session(&self, session_id: &str) -> Result<Option<Session>, SdkError> {
688        self.storage()
689            .load_session(session_id)
690            .await
691            .map_err(SdkError::Io)
692    }
693
694    /// The message history for a session, or [`SdkError::SessionNotFound`] if
695    /// it doesn't exist.
696    pub async fn session_history(&self, session_id: &str) -> Result<Vec<Message>, SdkError> {
697        self.get_session(session_id)
698            .await?
699            .map(|session| session.messages)
700            .ok_or_else(|| SdkError::SessionNotFound(session_id.to_string()))
701    }
702
703    /// Delete a session. Returns `true` if a session was actually deleted.
704    pub async fn delete_session(&self, session_id: &str) -> Result<bool, SdkError> {
705        self.storage()
706            .delete_session(session_id)
707            .await
708            .map_err(SdkError::Io)
709    }
710}
711
712/// Outcome of [`Agent::answer`] — the updated session, the recorded response,
713/// and any side effects `submit_pending_response` computed (plan-mode
714/// transitions, permission grants implied by an approval).
715#[derive(Debug)]
716pub struct AnswerOutcome {
717    /// The session after the pending question was answered (tool result
718    /// recorded, `pending_question` cleared, resume markers set).
719    pub session: Session,
720    /// The response text that was recorded.
721    pub response: String,
722    /// Plan-mode entered/exited transition, if the answered question was
723    /// `EnterPlanMode`/`ExitPlanMode`.
724    pub plan_mode_transition: Option<PlanModeTransition>,
725    /// `(PermissionType, resource)` grants implied by approving a permission
726    /// prompt (empty unless the pending question was a permission approval).
727    /// Already applied to this `Agent`'s configured
728    /// [`permission_checker`](AgentBuilder::permission_checker), if any.
729    pub permission_grants: Vec<(bamboo_tools::permission::PermissionType, String)>,
730}
731
732/// [`SessionAccess`] for [`Agent`], backed by [`Agent::storage`] (reads) and
733/// [`Agent::persistence`] (writes) — no separate cache tier, unlike the
734/// server's cache+storage+persistence-backed `SessionRepository`, since the
735/// SDK has no cross-request cache to keep coherent. This is what lets
736/// [`Agent::answer`] call the same `bamboo_engine::session_app::respond`
737/// use-case function the HTTP `/respond` handler calls on `AppState`.
738#[async_trait]
739impl SessionAccess for Agent {
740    async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
741        self.storage()
742            .load_session(id)
743            .await
744            .map_err(|e| SessionLoadError::StorageError(e.to_string()))
745    }
746
747    async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
748        match SessionAccess::load_session(self, id).await? {
749            Some(session) => Ok(session),
750            None => Ok(Session::new(id.to_string(), model.to_string())),
751        }
752    }
753
754    async fn load_merged(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
755        // No separate cache tier — storage is the single source of truth.
756        SessionAccess::load_session(self, id).await
757    }
758
759    async fn save_session(&self, session: &mut Session) -> Result<(), SessionSaveError> {
760        self.persistence()
761            .save_runtime_session(session)
762            .await
763            .map_err(|e| SessionSaveError::StorageError(e.to_string()))
764    }
765
766    async fn save_and_cache(&self, session: &mut Session) -> Result<(), SessionSaveError> {
767        SessionAccess::save_session(self, session).await
768    }
769}
770
771/// Find the original tool call (with its arguments) by id in the session
772/// history. Mirrors `bamboo-server`'s `resume_adapter::find_pending_tool_call`.
773fn find_pending_tool_call(
774    session: &Session,
775    tool_call_id: &str,
776) -> Option<bamboo_agent_core::tools::ToolCall> {
777    session.messages.iter().find_map(|message| {
778        message
779            .tool_calls
780            .as_ref()
781            .and_then(|calls| calls.iter().find(|call| call.id == tool_call_id).cloned())
782    })
783}
784
785/// Overwrite the tool-result message for `tool_call_id` with the real tool
786/// output. Mirrors `bamboo-server`'s `resume_adapter::apply_tool_result`.
787fn apply_tool_result(session: &mut Session, tool_call_id: &str, content: String, success: bool) {
788    for message in &mut session.messages {
789        if message.tool_call_id.as_deref() == Some(tool_call_id) {
790            message.content = content;
791            message.tool_success = Some(success);
792            return;
793        }
794    }
795}
796
797#[cfg(test)]
798mod approval_and_session_tests {
799    use super::*;
800    use bamboo_tools::permission::{
801        PermissionChecker, PermissionContext, PermissionError, PermissionMode, PermissionType,
802    };
803    use std::sync::Mutex as StdMutex;
804
805    /// A minimal data dir with a keyless-but-constructible provider config, so
806    /// `with_defaults_for_data_dir` succeeds without any network I/O — mirrors
807    /// `tests/agent_sdk.rs`'s `s_t4_3` setup.
808    async fn build_test_agent(data_dir: std::path::PathBuf) -> Agent {
809        let config_json = r#"{
810            "provider": "anthropic",
811            "providers": {
812                "anthropic": { "api_key": "test-key", "model": "claude-test" }
813            }
814        }"#;
815        std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
816
817        AgentBuilder::new()
818            .model("claude-test")
819            .instruction("test agent")
820            .with_defaults_for_data_dir(data_dir)
821            .await
822            .expect("defaults should assemble")
823            .build()
824            .expect("agent should build")
825    }
826
827    fn seed_session_with_pending_question(
828        session_id: &str,
829        options: Vec<String>,
830        allow_custom: bool,
831    ) -> Session {
832        let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
833        session.set_pending_question(
834            "call-1".to_string(),
835            "ConclusionWithOptions".to_string(),
836            "Pick one".to_string(),
837            options,
838            allow_custom,
839        );
840        session
841    }
842
843    #[tokio::test]
844    async fn answer_resolves_pending_question_and_persists() {
845        let tmp = tempfile::tempdir().expect("tempdir");
846        let agent = build_test_agent(tmp.path().to_path_buf()).await;
847
848        let session = seed_session_with_pending_question(
849            "sess-answer-ok",
850            vec!["A".to_string(), "B".to_string()],
851            false,
852        );
853        agent
854            .storage()
855            .save_session(&session)
856            .await
857            .expect("seed session");
858
859        let outcome = agent
860            .answer("sess-answer-ok", "A")
861            .await
862            .expect("answer should succeed");
863        assert_eq!(outcome.response, "A");
864        assert!(outcome.session.pending_question.is_none());
865        assert!(outcome.permission_grants.is_empty());
866
867        // Persisted: reloading independently shows the same state.
868        let reloaded = agent
869            .storage()
870            .load_session("sess-answer-ok")
871            .await
872            .expect("load")
873            .expect("present");
874        assert!(reloaded.pending_question.is_none());
875        assert!(reloaded
876            .messages
877            .iter()
878            .any(|m| m.tool_call_id.as_deref() == Some("call-1")
879                && m.content.contains("Selected response: A")));
880    }
881
882    #[tokio::test]
883    async fn answer_rejects_response_outside_fixed_options() {
884        let tmp = tempfile::tempdir().expect("tempdir");
885        let agent = build_test_agent(tmp.path().to_path_buf()).await;
886
887        let session = seed_session_with_pending_question(
888            "sess-answer-invalid",
889            vec!["A".to_string(), "B".to_string()],
890            false,
891        );
892        agent
893            .storage()
894            .save_session(&session)
895            .await
896            .expect("seed session");
897
898        let error = agent
899            .answer("sess-answer-invalid", "not-an-option")
900            .await
901            .expect_err("response outside options should be rejected");
902        assert!(matches!(error, SdkError::InvalidResponse(_)));
903    }
904
905    #[tokio::test]
906    async fn answer_errors_when_no_pending_question() {
907        let tmp = tempfile::tempdir().expect("tempdir");
908        let agent = build_test_agent(tmp.path().to_path_buf()).await;
909
910        let session = Session::new("sess-no-pending".to_string(), "claude-test".to_string());
911        agent
912            .storage()
913            .save_session(&session)
914            .await
915            .expect("seed session");
916
917        let error = agent
918            .answer("sess-no-pending", "anything")
919            .await
920            .expect_err("no pending question should error");
921        assert!(matches!(error, SdkError::NoPendingQuestion));
922    }
923
924    #[tokio::test]
925    async fn answer_errors_when_session_missing() {
926        let tmp = tempfile::tempdir().expect("tempdir");
927        let agent = build_test_agent(tmp.path().to_path_buf()).await;
928
929        let error = agent
930            .answer("does-not-exist", "anything")
931            .await
932            .expect_err("missing session should error");
933        assert!(matches!(error, SdkError::SessionNotFound(id) if id == "does-not-exist"));
934    }
935
936    #[tokio::test]
937    async fn session_ergonomics_list_get_history_delete_round_trip() {
938        let tmp = tempfile::tempdir().expect("tempdir");
939        let agent = build_test_agent(tmp.path().to_path_buf()).await;
940
941        let mut session_a = Session::new("sess-a".to_string(), "claude-test".to_string());
942        session_a.add_message(Message::user("hello"));
943        agent
944            .storage()
945            .save_session(&session_a)
946            .await
947            .expect("save a");
948
949        let session_b = Session::new("sess-b".to_string(), "claude-test".to_string());
950        agent
951            .storage()
952            .save_session(&session_b)
953            .await
954            .expect("save b");
955
956        let listed = agent.list_sessions().await.expect("list_sessions");
957        let ids: Vec<&str> = listed.iter().map(|entry| entry.id.as_str()).collect();
958        assert!(ids.contains(&"sess-a"));
959        assert!(ids.contains(&"sess-b"));
960
961        let history = agent
962            .session_history("sess-a")
963            .await
964            .expect("session_history");
965        assert_eq!(history.len(), 1);
966        assert_eq!(history[0].content, "hello");
967
968        let missing_history = agent.session_history("does-not-exist").await;
969        assert!(matches!(
970            missing_history,
971            Err(SdkError::SessionNotFound(id)) if id == "does-not-exist"
972        ));
973
974        let deleted = agent.delete_session("sess-a").await.expect("delete");
975        assert!(deleted);
976        assert!(agent
977            .get_session("sess-a")
978            .await
979            .expect("get_session")
980            .is_none());
981    }
982
983    /// A stub `PermissionChecker` that records one-shot grants
984    /// calls, so the test can assert `Agent::answer` applies the permission
985    /// grants `submit_pending_response` extracts from an approved permission
986    /// prompt — mirroring what the HTTP `/respond` handler does explicitly for
987    /// `state.permission_checker`.
988    #[derive(Default)]
989    struct RecordingPermissionChecker {
990        grants: StdMutex<Vec<(String, String, PermissionType, String)>>,
991    }
992
993    #[async_trait]
994    impl PermissionChecker for RecordingPermissionChecker {
995        async fn needs_confirmation(&self, _perm_type: PermissionType, _resource: &str) -> bool {
996            false
997        }
998
999        async fn request_confirmation(
1000            &self,
1001            _ctx: PermissionContext,
1002        ) -> Result<bool, PermissionError> {
1003            Ok(true)
1004        }
1005
1006        fn grant_session_permission(&self, perm_type: PermissionType, resource: String) {
1007            panic!("legacy unscoped grant used: {perm_type:?} {resource}");
1008        }
1009
1010        fn grant_once(
1011            &self,
1012            session_id: &str,
1013            request_id: &str,
1014            perm_type: PermissionType,
1015            resource: String,
1016        ) {
1017            self.grants.lock().unwrap().push((
1018                session_id.to_string(),
1019                request_id.to_string(),
1020                perm_type,
1021                resource,
1022            ));
1023        }
1024
1025        fn set_permission_mode(&self, _mode: PermissionMode) {}
1026    }
1027
1028    #[tokio::test]
1029    async fn answer_applies_permission_grants_to_configured_checker() {
1030        let tmp = tempfile::tempdir().expect("tempdir");
1031        let config_json = r#"{
1032            "provider": "anthropic",
1033            "providers": {
1034                "anthropic": { "api_key": "test-key", "model": "claude-test" }
1035            }
1036        }"#;
1037        std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
1038
1039        let checker = Arc::new(RecordingPermissionChecker::default());
1040        let agent = AgentBuilder::new()
1041            .model("claude-test")
1042            .permission_checker(checker.clone())
1043            .with_defaults_for_data_dir(tmp.path().to_path_buf())
1044            .await
1045            .expect("defaults should assemble")
1046            .build()
1047            .expect("agent should build");
1048
1049        // Seed a session suspended on an approved permission prompt: the
1050        // synthesized `awaiting_permission_approval` tool-result payload
1051        // `check_permissions_for` writes before pausing (see
1052        // `bamboo_tools::executor` / `session_app::respond`).
1053        let mut session = Session::new("sess-permission".to_string(), "claude-test".to_string());
1054        session.set_pending_question(
1055            "call-perm-1".to_string(),
1056            "Write".to_string(),
1057            "Permission required".to_string(),
1058            vec!["Approve".to_string(), "Deny".to_string()],
1059            false,
1060        );
1061        session.add_message(Message::tool_result(
1062            "call-perm-1",
1063            serde_json::json!({
1064                "status": "awaiting_permission_approval",
1065                "question": "Permission required",
1066                "permission_type": "write_file",
1067                "resource": "/tmp/example.txt",
1068                "options": ["Approve", "Deny"],
1069                "allow_custom": false,
1070            })
1071            .to_string(),
1072        ));
1073        agent
1074            .storage()
1075            .save_session(&session)
1076            .await
1077            .expect("seed session");
1078
1079        let outcome = agent
1080            .answer("sess-permission", "Approve")
1081            .await
1082            .expect("answer should succeed");
1083        assert_eq!(
1084            outcome.permission_grants,
1085            vec![(PermissionType::WriteFile, "/tmp/example.txt".to_string())]
1086        );
1087
1088        let recorded = checker.grants.lock().unwrap();
1089        assert_eq!(
1090            *recorded,
1091            vec![(
1092                "sess-permission".to_string(),
1093                "call-perm-1".to_string(),
1094                PermissionType::WriteFile,
1095                "/tmp/example.txt".to_string()
1096            )]
1097        );
1098    }
1099
1100    #[tokio::test]
1101    async fn list_sessions_unsupported_without_defaults_for_data_dir() {
1102        // An Agent wrapped directly via `from_runtime` (no session_store
1103        // handle) should report `Unsupported`, not panic.
1104        // Building one still needs a real engine Agent, so reuse the defaults
1105        // path and drop the handle to simulate a manually-injected Agent.
1106        let tmp = tempfile::tempdir().expect("tempdir");
1107        let agent = build_test_agent(tmp.path().to_path_buf()).await;
1108        let bare = Agent::from_runtime_with_config(
1109            // Reuse the inner engine agent — only the SDK-level session_store
1110            // handle is what `list_sessions` checks.
1111            agent.inner.clone(),
1112            None,
1113            None,
1114            None,
1115            None,
1116        );
1117        let result = bare.list_sessions().await;
1118        assert!(matches!(result, Err(SdkError::Unsupported(_))));
1119    }
1120}
1121
1122#[cfg(test)]
1123mod reexecute_and_child_approval_tests {
1124    use super::*;
1125    use bamboo_agent_core::tools::{FunctionCall, Tool, ToolCall, ToolCtx, ToolError, ToolOutcome};
1126    use std::sync::atomic::{AtomicUsize, Ordering};
1127
1128    /// A tool whose real output is trivially distinguishable from the
1129    /// synthetic "Selected response: Approve" placeholder `submit_pending_response`
1130    /// writes, and which counts invocations — so tests can assert it actually ran
1131    /// (not merely that the metadata marker was consumed).
1132    struct RealOutputTool {
1133        calls: AtomicUsize,
1134    }
1135
1136    impl RealOutputTool {
1137        fn new() -> Self {
1138            Self {
1139                calls: AtomicUsize::new(0),
1140            }
1141        }
1142    }
1143
1144    #[async_trait]
1145    impl Tool for RealOutputTool {
1146        fn name(&self) -> &str {
1147            "real_output_tool"
1148        }
1149
1150        fn description(&self) -> &str {
1151            "test-only tool that returns a distinctive real result"
1152        }
1153
1154        fn parameters_schema(&self) -> serde_json::Value {
1155            serde_json::json!({ "type": "object", "properties": {} })
1156        }
1157
1158        async fn invoke(
1159            &self,
1160            _args: serde_json::Value,
1161            _ctx: ToolCtx,
1162        ) -> Result<ToolOutcome, ToolError> {
1163            let n = self.calls.fetch_add(1, Ordering::SeqCst);
1164            Ok(ToolOutcome::Completed(
1165                bamboo_agent_core::tools::ToolResult::text(true, format!("REAL TOOL OUTPUT #{n}")),
1166            ))
1167        }
1168    }
1169
1170    async fn build_test_agent_with_tool(
1171        data_dir: std::path::PathBuf,
1172        tool: Arc<RealOutputTool>,
1173    ) -> Agent {
1174        let config_json = r#"{
1175            "provider": "anthropic",
1176            "providers": {
1177                "anthropic": { "api_key": "test-key", "model": "claude-test" }
1178            }
1179        }"#;
1180        std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
1181
1182        AgentBuilder::new()
1183            .model("claude-test")
1184            .instruction("test agent")
1185            .tool_shared(tool)
1186            .with_defaults_for_data_dir(data_dir)
1187            .await
1188            .expect("defaults should assemble")
1189            .build()
1190            .expect("agent should build")
1191    }
1192
1193    /// Seed a session suspended on an approved permission prompt for
1194    /// `real_output_tool`, matching the shape `check_permissions_for` writes
1195    /// before pausing: an assistant message carrying the gated tool call, plus
1196    /// the synthesized `awaiting_permission_approval` tool-result payload.
1197    fn seed_gated_tool_session(session_id: &str, tool_call_id: &str) -> Session {
1198        let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
1199        session.add_message(Message::assistant(
1200            "",
1201            Some(vec![ToolCall {
1202                id: tool_call_id.to_string(),
1203                tool_type: "function".to_string(),
1204                function: FunctionCall {
1205                    name: "real_output_tool".to_string(),
1206                    arguments: "{}".to_string(),
1207                },
1208            }]),
1209        ));
1210        session.set_pending_question(
1211            tool_call_id.to_string(),
1212            "real_output_tool".to_string(),
1213            "Permission required".to_string(),
1214            vec!["Approve".to_string(), "Deny".to_string()],
1215            false,
1216        );
1217        session.add_message(Message::tool_result(
1218            tool_call_id,
1219            serde_json::json!({
1220                "status": "awaiting_permission_approval",
1221                "question": "Permission required",
1222                "permission_type": "write_file",
1223                "resource": "/tmp/example.txt",
1224                "options": ["Approve", "Deny"],
1225                "allow_custom": false,
1226            })
1227            .to_string(),
1228        ));
1229        session
1230    }
1231
1232    #[tokio::test]
1233    async fn approve_marks_session_for_reexecution() {
1234        let tmp = tempfile::tempdir().expect("tempdir");
1235        let tool = Arc::new(RealOutputTool::new());
1236        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1237
1238        let session = seed_gated_tool_session("sess-mark", "call-mark-1");
1239        agent
1240            .storage()
1241            .save_session(&session)
1242            .await
1243            .expect("seed session");
1244
1245        let outcome = agent
1246            .answer("sess-mark", "Approve")
1247            .await
1248            .expect("answer should succeed");
1249
1250        assert_eq!(
1251            outcome
1252                .session
1253                .metadata
1254                .get(PERMISSION_REEXECUTE_METADATA_KEY)
1255                .map(String::as_str),
1256            Some("call-mark-1"),
1257            "approving a permission prompt must stamp the re-exec marker"
1258        );
1259    }
1260
1261    #[tokio::test]
1262    async fn deny_does_not_mark_session_for_reexecution() {
1263        let tmp = tempfile::tempdir().expect("tempdir");
1264        let tool = Arc::new(RealOutputTool::new());
1265        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1266
1267        let session = seed_gated_tool_session("sess-deny", "call-deny-1");
1268        agent
1269            .storage()
1270            .save_session(&session)
1271            .await
1272            .expect("seed session");
1273
1274        let outcome = agent
1275            .answer("sess-deny", "Deny")
1276            .await
1277            .expect("answer should succeed");
1278
1279        assert!(outcome.permission_grants.is_empty());
1280        assert!(!outcome
1281            .session
1282            .metadata
1283            .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
1284        // The tool result stays the synthetic "Selected response: Deny" — the
1285        // gated tool must NOT have run.
1286        let tool_message = outcome
1287            .session
1288            .messages
1289            .iter()
1290            .find(|m| m.tool_call_id.as_deref() == Some("call-deny-1"))
1291            .expect("tool result message present");
1292        assert_eq!(tool_message.content, "Selected response: Deny");
1293    }
1294
1295    #[tokio::test]
1296    async fn approve_then_reexecute_runs_real_tool_and_overwrites_placeholder() {
1297        let tmp = tempfile::tempdir().expect("tempdir");
1298        let tool = Arc::new(RealOutputTool::new());
1299        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1300
1301        let session = seed_gated_tool_session("sess-reexec", "call-reexec-1");
1302        agent
1303            .storage()
1304            .save_session(&session)
1305            .await
1306            .expect("seed session");
1307
1308        let outcome = agent
1309            .answer("sess-reexec", "Approve")
1310            .await
1311            .expect("answer should succeed");
1312        let mut session = outcome.session;
1313
1314        // Sanity: before re-execution the tool result is still the synthetic
1315        // placeholder, and the real tool has not run yet.
1316        let placeholder = session
1317            .messages
1318            .iter()
1319            .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1320            .expect("tool result message present");
1321        assert_eq!(placeholder.content, "Selected response: Approve");
1322        assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1323
1324        // Drive the same re-execution step `resume`/`run*` apply internally
1325        // (via `execute_internal`) at the top of the next execution.
1326        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1327        agent
1328            .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1329            .await;
1330        drop(event_tx);
1331
1332        // The gated tool ran exactly once, and the placeholder is replaced with
1333        // its real output.
1334        assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
1335        let real_result = session
1336            .messages
1337            .iter()
1338            .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1339            .expect("tool result message present");
1340        assert_eq!(real_result.content, "REAL TOOL OUTPUT #0");
1341        assert_eq!(real_result.tool_success, Some(true));
1342        assert!(
1343            !session
1344                .metadata
1345                .contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
1346            "the marker must be consumed (removed) after re-execution"
1347        );
1348
1349        // Lifecycle events (ToolStart-equivalent begin + ToolComplete) were
1350        // emitted, matching what a normal dispatch would stream.
1351        let mut saw_tool_complete = false;
1352        while let Ok(event) = event_rx.try_recv() {
1353            if let AgentEvent::ToolComplete { tool_call_id, .. } = event {
1354                assert_eq!(tool_call_id, "call-reexec-1");
1355                saw_tool_complete = true;
1356            }
1357        }
1358        assert!(saw_tool_complete, "expected a ToolComplete event");
1359
1360        // Persisted too (best-effort save inside the helper).
1361        let reloaded = agent
1362            .storage()
1363            .load_session("sess-reexec")
1364            .await
1365            .expect("load")
1366            .expect("present");
1367        let reloaded_result = reloaded
1368            .messages
1369            .iter()
1370            .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1371            .expect("tool result message present");
1372        assert_eq!(reloaded_result.content, "REAL TOOL OUTPUT #0");
1373    }
1374
1375    #[tokio::test]
1376    async fn reexecute_is_noop_without_pending_marker() {
1377        let tmp = tempfile::tempdir().expect("tempdir");
1378        let tool = Arc::new(RealOutputTool::new());
1379        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1380
1381        let mut session = Session::new("sess-noop".to_string(), "claude-test".to_string());
1382        session.add_message(Message::user("hi"));
1383
1384        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
1385        agent
1386            .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1387            .await;
1388
1389        assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1390        assert_eq!(session.messages.len(), 1);
1391    }
1392
1393    #[tokio::test]
1394    async fn reexecute_warns_and_clears_marker_when_tool_call_missing() {
1395        let tmp = tempfile::tempdir().expect("tempdir");
1396        let tool = Arc::new(RealOutputTool::new());
1397        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1398
1399        let mut session = Session::new("sess-missing".to_string(), "claude-test".to_string());
1400        // Marker set, but no matching tool_calls entry exists in history.
1401        session.metadata.insert(
1402            PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1403            "ghost-call".to_string(),
1404        );
1405
1406        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1407        agent
1408            .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1409            .await;
1410        drop(event_tx);
1411
1412        assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1413        assert!(event_rx.try_recv().is_err(), "no events should be emitted");
1414        assert!(
1415            !session
1416                .metadata
1417                .contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
1418            "the marker is removed even when the tool call can't be found, so a \
1419             missing/pruned call can't wedge every future execution"
1420        );
1421    }
1422
1423    #[tokio::test]
1424    async fn answer_child_approval_delivers_only_genuinely_pending_requests() {
1425        let tmp = tempfile::tempdir().expect("tempdir");
1426        let tool = Arc::new(RealOutputTool::new());
1427        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1428
1429        // Unregistered pair: rejected, mirroring an unmatched HTTP POST.
1430        assert!(!agent.answer_child_approval("child-x", "req-unknown", true));
1431
1432        // A live child connection (as the actor adapter registers for the
1433        // duration of a running child) plus the pending-approval marker it
1434        // records just before surfacing `ChildApprovalRequested` — both are
1435        // process-global engine state, set up here exactly as
1436        // `external_agents::actor_adapter::drive` would.
1437        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1438        let _live_guard = bamboo_engine::external_agents::live::register("child-x", tx, 0, None);
1439        let (approval_event_tx, _approval_event_rx) = tokio::sync::mpsc::channel(4);
1440        bamboo_engine::external_agents::live::register_pending_approval_observed(
1441            None,
1442            "parent-x",
1443            "child-x",
1444            0,
1445            "req-1",
1446            "shell",
1447            "execute",
1448            "cargo test",
1449            approval_event_tx,
1450        );
1451
1452        assert!(agent.answer_child_approval("child-x", "req-1", true));
1453        match rx.try_recv() {
1454            Ok(bamboo_subagent::proto::ParentFrame::ApprovalReply { id, approved }) => {
1455                assert_eq!(id, "req-1");
1456                assert!(approved);
1457            }
1458            other => panic!("expected an ApprovalReply frame, got {other:?}"),
1459        }
1460
1461        // One-shot: a replay of the same request_id is rejected.
1462        assert!(!agent.answer_child_approval("child-x", "req-1", true));
1463    }
1464}