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