mod builder;
mod error;
mod execute_request;
mod tools;
use std::sync::Arc;
use async_trait::async_trait;
pub use builder::AgentBuilder;
pub use execute_request::ExecuteRequestBuilder;
use tokio::sync::mpsc;
use bamboo_engine::session_app::errors::{SessionLoadError, SessionSaveError};
use bamboo_engine::session_app::repository::SessionAccess;
use bamboo_engine::session_app::respond::{
submit_pending_response, PERMISSION_REEXECUTE_METADATA_KEY,
};
use bamboo_engine::session_app::types::RespondInput;
pub use tokio_util::sync::CancellationToken;
pub use tools::{
builtin_tool_names, builtin_tool_specs, BuiltinTool, ToolSpec, CANONICAL_TOOL_NAMES,
};
pub use error::SdkError;
pub use bamboo_agent_core::{
AgentError, AgentEvent, Message, MessageContent, PendingQuestion, Role, Session,
TokenBudgetUsage, TokenUsage,
};
pub use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
pub use bamboo_engine::session_app::respond::PlanModeTransition;
pub use bamboo_engine::ExecuteRequest;
pub use bamboo_llm::LLMProvider;
pub use bamboo_mcp::manager::McpServerManager;
pub use bamboo_mcp::McpServerConfig;
pub use bamboo_storage::SessionIndexEntry;
pub use bamboo_tools::permission::{PermissionChecker, PermissionType};
pub use bamboo_tools::{BuiltinToolExecutor, BuiltinToolExecutorBuilder, ToolOutputManager};
const EVENT_CHANNEL_CAPACITY: usize = 256;
#[derive(Clone)]
pub struct Agent {
inner: bamboo_engine::Agent,
system_prompt: Option<String>,
model: Option<String>,
session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
}
impl Agent {
pub fn builder() -> AgentBuilder {
AgentBuilder::new()
}
pub fn from_runtime(inner: bamboo_engine::Agent) -> Self {
Self {
inner,
system_prompt: None,
model: None,
session_store: None,
permission_checker: None,
}
}
pub(crate) fn from_runtime_with_config(
inner: bamboo_engine::Agent,
system_prompt: Option<String>,
model: Option<String>,
session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
) -> Self {
Self {
inner,
system_prompt,
model,
session_store,
permission_checker,
}
}
pub async fn run(
&self,
session: &mut Session,
input: impl Into<String>,
) -> Result<(), AgentError> {
session.add_message(Message::user(input.into()));
self.run_session(session).await
}
pub async fn run_with_cancel(
&self,
session: &mut Session,
input: impl Into<String>,
cancel_token: CancellationToken,
) -> Result<(), AgentError> {
session.add_message(Message::user(input.into()));
self.run_session_with_cancel(session, cancel_token).await
}
pub async fn run_session(&self, session: &mut Session) -> Result<(), AgentError> {
self.run_session_with_cancel(session, CancellationToken::new())
.await
}
pub async fn run_session_with_cancel(
&self,
session: &mut Session,
cancel_token: CancellationToken,
) -> Result<(), AgentError> {
let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
let drain = tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
let result = self.execute_internal(session, event_tx, cancel_token).await;
drain.abort();
result
}
pub fn run_stream(
&self,
mut session: Session,
input: impl Into<String>,
) -> mpsc::Receiver<AgentEvent> {
session.add_message(Message::user(input.into()));
self.run_stream_session(session)
}
pub fn run_stream_cancellable(
&self,
mut session: Session,
input: impl Into<String>,
) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
session.add_message(Message::user(input.into()));
self.run_stream_session_cancellable(session)
}
pub fn run_stream_session(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
self.run_stream_session_with_cancel(session, CancellationToken::new())
}
pub fn run_stream_session_cancellable(
&self,
session: Session,
) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
let cancel_token = CancellationToken::new();
let rx = self.run_stream_session_with_cancel(session, cancel_token.clone());
(rx, cancel_token)
}
pub fn run_stream_session_with_cancel(
&self,
mut session: Session,
cancel_token: CancellationToken,
) -> mpsc::Receiver<AgentEvent> {
let (event_tx, event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
let agent = self.clone();
tokio::spawn(async move {
if let Err(error) = agent
.execute_internal(&mut session, event_tx, cancel_token)
.await
{
tracing::warn!("Agent::run_stream execution failed: {error}");
}
});
event_rx
}
pub async fn execute(
&self,
session: &mut Session,
request: ExecuteRequest,
) -> Result<(), AgentError> {
self.inner.execute(session, request).await
}
async fn execute_internal(
&self,
session: &mut Session,
event_tx: mpsc::Sender<AgentEvent>,
cancel_token: CancellationToken,
) -> Result<(), AgentError> {
self.reexecute_approved_tool_if_pending(session, &event_tx)
.await;
bamboo_engine::session_app::execution_prep::prepare_session_for_execution(
session,
self.system_prompt.as_deref(),
self.model.as_deref(),
);
let initial_message = session
.messages
.iter()
.rev()
.find(|m| matches!(m.role, Role::User))
.map(|m| m.content.clone())
.unwrap_or_default();
let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token);
if let Some(model) = self.model.clone() {
builder = builder.model(model);
}
self.inner.execute(session, builder.build()).await
}
async fn reexecute_approved_tool_if_pending(
&self,
session: &mut Session,
event_tx: &mpsc::Sender<AgentEvent>,
) {
let Some(tool_call_id) = session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY) else {
return;
};
let Some(tool_call) = find_pending_tool_call(session, &tool_call_id) else {
tracing::warn!(
session_id = %session.id,
tool_call_id = %tool_call_id,
"Permission re-exec marker set but tool call not found in history"
);
return;
};
let executor = self.inner.default_tools();
let tool_name = tool_call.function.name.clone();
let is_mutating = bamboo_tools::orchestrator::classify_tool(&tool_name)
== bamboo_tools::orchestrator::ToolMutability::Mutating;
let mut emitter = bamboo_tools::ToolEmitter::new(&tool_call.id, &tool_name, is_mutating);
emitter.set_auto_approved(true);
let _ = event_tx
.send(emitter.begin().clone().into_agent_event())
.await;
let exec_result = {
let ctx = bamboo_agent_core::tools::ToolExecutionContext {
session_id: Some(session.id.as_str()),
tool_call_id: tool_call_id.as_str(),
event_tx: Some(event_tx),
available_tool_schemas: None,
bypass_permissions: false,
can_async_resume: false,
bash_completion_sink: None,
pre_parsed_args: None,
};
executor.execute_with_context(&tool_call, ctx).await
};
let (content, success) = match exec_result {
Ok(tool_result) => {
let _ = event_tx
.send(
emitter
.finish(Some("Re-executed after approval".to_string()))
.clone()
.into_agent_event(),
)
.await;
let _ = event_tx
.send(AgentEvent::ToolComplete {
tool_call_id: tool_call.id.clone(),
result: tool_result.clone(),
})
.await;
(tool_result.result, tool_result.success)
}
Err(error) => {
let message = format!("Tool re-execution after approval failed: {error}");
let _ = event_tx
.send(emitter.error(message.clone()).clone().into_agent_event())
.await;
(message, false)
}
};
tracing::info!(
session_id = %session.id,
tool_name = %tool_name,
tool_call_id = %tool_call_id,
success,
"Re-executed approved tool after permission grant"
);
apply_tool_result(session, &tool_call_id, content, success);
if let Err(error) = self.persistence().save_runtime_session(session).await {
tracing::warn!(
session_id = %session.id,
%error,
"Failed to persist session after tool re-execution (loop's own save will retry)"
);
}
}
pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
self.inner.storage()
}
pub fn persistence(&self) -> &Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
self.inner.persistence()
}
pub async fn answer(
&self,
session_id: impl Into<String>,
response: impl Into<String>,
) -> Result<AnswerOutcome, SdkError> {
let input = RespondInput {
session_id: session_id.into(),
user_response: response.into(),
model: None,
model_ref: None,
provider: None,
reasoning_effort: None,
};
let (session, response, plan_mode_transition, permission_grants) =
submit_pending_response(self, input).await?;
if let Some(checker) = &self.permission_checker {
for (perm_type, resource) in &permission_grants {
checker.grant_session_permission(*perm_type, resource.clone());
}
}
Ok(AnswerOutcome {
session,
response,
plan_mode_transition,
permission_grants,
})
}
pub async fn resume(&self, session: &mut Session) -> Result<(), AgentError> {
self.run_session(session).await
}
pub async fn resume_with_cancel(
&self,
session: &mut Session,
cancel_token: CancellationToken,
) -> Result<(), AgentError> {
self.run_session_with_cancel(session, cancel_token).await
}
pub fn resume_stream(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
self.run_stream_session(session)
}
pub fn resume_stream_cancellable(
&self,
session: Session,
) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
self.run_stream_session_cancellable(session)
}
pub async fn answer_and_resume_stream(
&self,
session_id: impl Into<String>,
response: impl Into<String>,
) -> Result<mpsc::Receiver<AgentEvent>, SdkError> {
let outcome = self.answer(session_id, response).await?;
Ok(self.resume_stream(outcome.session))
}
pub fn answer_child_approval(
&self,
child_session_id: impl AsRef<str>,
request_id: impl AsRef<str>,
approved: bool,
) -> bool {
bamboo_engine::external_agents::live::deliver_approval_checked(
child_session_id.as_ref(),
request_id.as_ref(),
approved,
)
}
pub async fn list_sessions(&self) -> Result<Vec<bamboo_storage::SessionIndexEntry>, SdkError> {
let store = self.session_store.as_ref().ok_or_else(|| {
SdkError::Unsupported(
"list_sessions requires an Agent built via with_defaults_for_data_dir".to_string(),
)
})?;
Ok(store.list_index_entries().await)
}
pub async fn get_session(&self, session_id: &str) -> Result<Option<Session>, SdkError> {
self.storage()
.load_session(session_id)
.await
.map_err(SdkError::Io)
}
pub async fn session_history(&self, session_id: &str) -> Result<Vec<Message>, SdkError> {
self.get_session(session_id)
.await?
.map(|session| session.messages)
.ok_or_else(|| SdkError::SessionNotFound(session_id.to_string()))
}
pub async fn delete_session(&self, session_id: &str) -> Result<bool, SdkError> {
self.storage()
.delete_session(session_id)
.await
.map_err(SdkError::Io)
}
}
#[derive(Debug)]
pub struct AnswerOutcome {
pub session: Session,
pub response: String,
pub plan_mode_transition: Option<PlanModeTransition>,
pub permission_grants: Vec<(bamboo_tools::permission::PermissionType, String)>,
}
#[async_trait]
impl SessionAccess for Agent {
async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
self.storage()
.load_session(id)
.await
.map_err(|e| SessionLoadError::StorageError(e.to_string()))
}
async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
match SessionAccess::load_session(self, id).await? {
Some(session) => Ok(session),
None => Ok(Session::new(id.to_string(), model.to_string())),
}
}
async fn load_merged(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
SessionAccess::load_session(self, id).await
}
async fn save_session(&self, session: &mut Session) -> Result<(), SessionSaveError> {
self.persistence()
.save_runtime_session(session)
.await
.map_err(|e| SessionSaveError::StorageError(e.to_string()))
}
async fn save_and_cache(&self, session: &mut Session) -> Result<(), SessionSaveError> {
SessionAccess::save_session(self, session).await
}
}
fn find_pending_tool_call(
session: &Session,
tool_call_id: &str,
) -> Option<bamboo_agent_core::tools::ToolCall> {
session.messages.iter().find_map(|message| {
message
.tool_calls
.as_ref()
.and_then(|calls| calls.iter().find(|call| call.id == tool_call_id).cloned())
})
}
fn apply_tool_result(session: &mut Session, tool_call_id: &str, content: String, success: bool) {
for message in &mut session.messages {
if message.tool_call_id.as_deref() == Some(tool_call_id) {
message.content = content;
message.tool_success = Some(success);
return;
}
}
}
#[cfg(test)]
mod approval_and_session_tests {
use super::*;
use bamboo_tools::permission::{
PermissionChecker, PermissionContext, PermissionError, PermissionMode, PermissionType,
};
use std::sync::Mutex as StdMutex;
async fn build_test_agent(data_dir: std::path::PathBuf) -> Agent {
let config_json = r#"{
"provider": "anthropic",
"providers": {
"anthropic": { "api_key": "test-key", "model": "claude-test" }
}
}"#;
std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
AgentBuilder::new()
.model("claude-test")
.instruction("test agent")
.with_defaults_for_data_dir(data_dir)
.await
.expect("defaults should assemble")
.build()
.expect("agent should build")
}
fn seed_session_with_pending_question(
session_id: &str,
options: Vec<String>,
allow_custom: bool,
) -> Session {
let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
session.set_pending_question(
"call-1".to_string(),
"ConclusionWithOptions".to_string(),
"Pick one".to_string(),
options,
allow_custom,
);
session
}
#[tokio::test]
async fn answer_resolves_pending_question_and_persists() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = build_test_agent(tmp.path().to_path_buf()).await;
let session = seed_session_with_pending_question(
"sess-answer-ok",
vec!["A".to_string(), "B".to_string()],
false,
);
agent
.storage()
.save_session(&session)
.await
.expect("seed session");
let outcome = agent
.answer("sess-answer-ok", "A")
.await
.expect("answer should succeed");
assert_eq!(outcome.response, "A");
assert!(outcome.session.pending_question.is_none());
assert!(outcome.permission_grants.is_empty());
let reloaded = agent
.storage()
.load_session("sess-answer-ok")
.await
.expect("load")
.expect("present");
assert!(reloaded.pending_question.is_none());
assert!(reloaded
.messages
.iter()
.any(|m| m.tool_call_id.as_deref() == Some("call-1")
&& m.content.contains("Selected response: A")));
}
#[tokio::test]
async fn answer_rejects_response_outside_fixed_options() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = build_test_agent(tmp.path().to_path_buf()).await;
let session = seed_session_with_pending_question(
"sess-answer-invalid",
vec!["A".to_string(), "B".to_string()],
false,
);
agent
.storage()
.save_session(&session)
.await
.expect("seed session");
let error = agent
.answer("sess-answer-invalid", "not-an-option")
.await
.expect_err("response outside options should be rejected");
assert!(matches!(error, SdkError::InvalidResponse(_)));
}
#[tokio::test]
async fn answer_errors_when_no_pending_question() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = build_test_agent(tmp.path().to_path_buf()).await;
let session = Session::new("sess-no-pending".to_string(), "claude-test".to_string());
agent
.storage()
.save_session(&session)
.await
.expect("seed session");
let error = agent
.answer("sess-no-pending", "anything")
.await
.expect_err("no pending question should error");
assert!(matches!(error, SdkError::NoPendingQuestion));
}
#[tokio::test]
async fn answer_errors_when_session_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = build_test_agent(tmp.path().to_path_buf()).await;
let error = agent
.answer("does-not-exist", "anything")
.await
.expect_err("missing session should error");
assert!(matches!(error, SdkError::SessionNotFound(id) if id == "does-not-exist"));
}
#[tokio::test]
async fn session_ergonomics_list_get_history_delete_round_trip() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = build_test_agent(tmp.path().to_path_buf()).await;
let mut session_a = Session::new("sess-a".to_string(), "claude-test".to_string());
session_a.add_message(Message::user("hello"));
agent
.storage()
.save_session(&session_a)
.await
.expect("save a");
let session_b = Session::new("sess-b".to_string(), "claude-test".to_string());
agent
.storage()
.save_session(&session_b)
.await
.expect("save b");
let listed = agent.list_sessions().await.expect("list_sessions");
let ids: Vec<&str> = listed.iter().map(|entry| entry.id.as_str()).collect();
assert!(ids.contains(&"sess-a"));
assert!(ids.contains(&"sess-b"));
let history = agent
.session_history("sess-a")
.await
.expect("session_history");
assert_eq!(history.len(), 1);
assert_eq!(history[0].content, "hello");
let missing_history = agent.session_history("does-not-exist").await;
assert!(matches!(
missing_history,
Err(SdkError::SessionNotFound(id)) if id == "does-not-exist"
));
let deleted = agent.delete_session("sess-a").await.expect("delete");
assert!(deleted);
assert!(agent
.get_session("sess-a")
.await
.expect("get_session")
.is_none());
}
#[derive(Default)]
struct RecordingPermissionChecker {
grants: StdMutex<Vec<(PermissionType, String)>>,
}
#[async_trait]
impl PermissionChecker for RecordingPermissionChecker {
async fn needs_confirmation(&self, _perm_type: PermissionType, _resource: &str) -> bool {
false
}
async fn request_confirmation(
&self,
_ctx: PermissionContext,
) -> Result<bool, PermissionError> {
Ok(true)
}
fn grant_session_permission(&self, perm_type: PermissionType, resource: String) {
self.grants.lock().unwrap().push((perm_type, resource));
}
fn set_permission_mode(&self, _mode: PermissionMode) {}
}
#[tokio::test]
async fn answer_applies_permission_grants_to_configured_checker() {
let tmp = tempfile::tempdir().expect("tempdir");
let config_json = r#"{
"provider": "anthropic",
"providers": {
"anthropic": { "api_key": "test-key", "model": "claude-test" }
}
}"#;
std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
let checker = Arc::new(RecordingPermissionChecker::default());
let agent = AgentBuilder::new()
.model("claude-test")
.permission_checker(checker.clone())
.with_defaults_for_data_dir(tmp.path().to_path_buf())
.await
.expect("defaults should assemble")
.build()
.expect("agent should build");
let mut session = Session::new("sess-permission".to_string(), "claude-test".to_string());
session.set_pending_question(
"call-perm-1".to_string(),
"Write".to_string(),
"Permission required".to_string(),
vec!["Approve".to_string(), "Deny".to_string()],
false,
);
session.add_message(Message::tool_result(
"call-perm-1",
serde_json::json!({
"status": "awaiting_permission_approval",
"question": "Permission required",
"permission_type": "write_file",
"resource": "/tmp/example.txt",
"options": ["Approve", "Deny"],
"allow_custom": false,
})
.to_string(),
));
agent
.storage()
.save_session(&session)
.await
.expect("seed session");
let outcome = agent
.answer("sess-permission", "Approve")
.await
.expect("answer should succeed");
assert_eq!(
outcome.permission_grants,
vec![(PermissionType::WriteFile, "/tmp/example.txt".to_string())]
);
let recorded = checker.grants.lock().unwrap();
assert_eq!(
*recorded,
vec![(PermissionType::WriteFile, "/tmp/example.txt".to_string())]
);
}
#[tokio::test]
async fn list_sessions_unsupported_without_defaults_for_data_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = build_test_agent(tmp.path().to_path_buf()).await;
let bare = Agent::from_runtime_with_config(
agent.inner.clone(),
None,
None,
None,
None,
);
let result = bare.list_sessions().await;
assert!(matches!(result, Err(SdkError::Unsupported(_))));
}
}
#[cfg(test)]
mod reexecute_and_child_approval_tests {
use super::*;
use bamboo_agent_core::tools::{FunctionCall, Tool, ToolCall, ToolCtx, ToolError, ToolOutcome};
use std::sync::atomic::{AtomicUsize, Ordering};
struct RealOutputTool {
calls: AtomicUsize,
}
impl RealOutputTool {
fn new() -> Self {
Self {
calls: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl Tool for RealOutputTool {
fn name(&self) -> &str {
"real_output_tool"
}
fn description(&self) -> &str {
"test-only tool that returns a distinctive real result"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({ "type": "object", "properties": {} })
}
async fn invoke(
&self,
_args: serde_json::Value,
_ctx: ToolCtx,
) -> Result<ToolOutcome, ToolError> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
Ok(ToolOutcome::Completed(
bamboo_agent_core::tools::ToolResult::text(true, format!("REAL TOOL OUTPUT #{n}")),
))
}
}
async fn build_test_agent_with_tool(
data_dir: std::path::PathBuf,
tool: Arc<RealOutputTool>,
) -> Agent {
let config_json = r#"{
"provider": "anthropic",
"providers": {
"anthropic": { "api_key": "test-key", "model": "claude-test" }
}
}"#;
std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
AgentBuilder::new()
.model("claude-test")
.instruction("test agent")
.tool_shared(tool)
.with_defaults_for_data_dir(data_dir)
.await
.expect("defaults should assemble")
.build()
.expect("agent should build")
}
fn seed_gated_tool_session(session_id: &str, tool_call_id: &str) -> Session {
let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
session.add_message(Message::assistant(
"",
Some(vec![ToolCall {
id: tool_call_id.to_string(),
tool_type: "function".to_string(),
function: FunctionCall {
name: "real_output_tool".to_string(),
arguments: "{}".to_string(),
},
}]),
));
session.set_pending_question(
tool_call_id.to_string(),
"real_output_tool".to_string(),
"Permission required".to_string(),
vec!["Approve".to_string(), "Deny".to_string()],
false,
);
session.add_message(Message::tool_result(
tool_call_id,
serde_json::json!({
"status": "awaiting_permission_approval",
"question": "Permission required",
"permission_type": "write_file",
"resource": "/tmp/example.txt",
"options": ["Approve", "Deny"],
"allow_custom": false,
})
.to_string(),
));
session
}
#[tokio::test]
async fn approve_marks_session_for_reexecution() {
let tmp = tempfile::tempdir().expect("tempdir");
let tool = Arc::new(RealOutputTool::new());
let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
let session = seed_gated_tool_session("sess-mark", "call-mark-1");
agent
.storage()
.save_session(&session)
.await
.expect("seed session");
let outcome = agent
.answer("sess-mark", "Approve")
.await
.expect("answer should succeed");
assert_eq!(
outcome
.session
.metadata
.get(PERMISSION_REEXECUTE_METADATA_KEY)
.map(String::as_str),
Some("call-mark-1"),
"approving a permission prompt must stamp the re-exec marker"
);
}
#[tokio::test]
async fn deny_does_not_mark_session_for_reexecution() {
let tmp = tempfile::tempdir().expect("tempdir");
let tool = Arc::new(RealOutputTool::new());
let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
let session = seed_gated_tool_session("sess-deny", "call-deny-1");
agent
.storage()
.save_session(&session)
.await
.expect("seed session");
let outcome = agent
.answer("sess-deny", "Deny")
.await
.expect("answer should succeed");
assert!(outcome.permission_grants.is_empty());
assert!(!outcome
.session
.metadata
.contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
let tool_message = outcome
.session
.messages
.iter()
.find(|m| m.tool_call_id.as_deref() == Some("call-deny-1"))
.expect("tool result message present");
assert_eq!(tool_message.content, "Selected response: Deny");
}
#[tokio::test]
async fn approve_then_reexecute_runs_real_tool_and_overwrites_placeholder() {
let tmp = tempfile::tempdir().expect("tempdir");
let tool = Arc::new(RealOutputTool::new());
let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
let session = seed_gated_tool_session("sess-reexec", "call-reexec-1");
agent
.storage()
.save_session(&session)
.await
.expect("seed session");
let outcome = agent
.answer("sess-reexec", "Approve")
.await
.expect("answer should succeed");
let mut session = outcome.session;
let placeholder = session
.messages
.iter()
.find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
.expect("tool result message present");
assert_eq!(placeholder.content, "Selected response: Approve");
assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
agent
.reexecute_approved_tool_if_pending(&mut session, &event_tx)
.await;
drop(event_tx);
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
let real_result = session
.messages
.iter()
.find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
.expect("tool result message present");
assert_eq!(real_result.content, "REAL TOOL OUTPUT #0");
assert_eq!(real_result.tool_success, Some(true));
assert!(
!session
.metadata
.contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
"the marker must be consumed (removed) after re-execution"
);
let mut saw_tool_complete = false;
while let Ok(event) = event_rx.try_recv() {
if let AgentEvent::ToolComplete { tool_call_id, .. } = event {
assert_eq!(tool_call_id, "call-reexec-1");
saw_tool_complete = true;
}
}
assert!(saw_tool_complete, "expected a ToolComplete event");
let reloaded = agent
.storage()
.load_session("sess-reexec")
.await
.expect("load")
.expect("present");
let reloaded_result = reloaded
.messages
.iter()
.find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
.expect("tool result message present");
assert_eq!(reloaded_result.content, "REAL TOOL OUTPUT #0");
}
#[tokio::test]
async fn reexecute_is_noop_without_pending_marker() {
let tmp = tempfile::tempdir().expect("tempdir");
let tool = Arc::new(RealOutputTool::new());
let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
let mut session = Session::new("sess-noop".to_string(), "claude-test".to_string());
session.add_message(Message::user("hi"));
let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
agent
.reexecute_approved_tool_if_pending(&mut session, &event_tx)
.await;
assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
assert_eq!(session.messages.len(), 1);
}
#[tokio::test]
async fn reexecute_warns_and_clears_marker_when_tool_call_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
let tool = Arc::new(RealOutputTool::new());
let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
let mut session = Session::new("sess-missing".to_string(), "claude-test".to_string());
session.metadata.insert(
PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
"ghost-call".to_string(),
);
let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
agent
.reexecute_approved_tool_if_pending(&mut session, &event_tx)
.await;
drop(event_tx);
assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
assert!(event_rx.try_recv().is_err(), "no events should be emitted");
assert!(
!session
.metadata
.contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
"the marker is removed even when the tool call can't be found, so a \
missing/pruned call can't wedge every future execution"
);
}
#[tokio::test]
async fn answer_child_approval_delivers_only_genuinely_pending_requests() {
let tmp = tempfile::tempdir().expect("tempdir");
let tool = Arc::new(RealOutputTool::new());
let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
assert!(!agent.answer_child_approval("child-x", "req-unknown", true));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let _live_guard = bamboo_engine::external_agents::live::register("child-x", tx);
bamboo_engine::external_agents::live::register_pending_approval("child-x", "req-1");
assert!(agent.answer_child_approval("child-x", "req-1", true));
match rx.try_recv() {
Ok(bamboo_subagent::proto::ParentFrame::ApprovalReply { id, approved }) => {
assert_eq!(id, "req-1");
assert!(approved);
}
other => panic!("expected an ApprovalReply frame, got {other:?}"),
}
assert!(!agent.answer_child_approval("child-x", "req-1", true));
}
}