1mod 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
58pub 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
67pub 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
85const EVENT_CHANNEL_CAPACITY: usize = 256;
87
88#[derive(Clone)]
93pub struct Agent {
94 inner: bamboo_engine::Agent,
95 system_prompt: Option<String>,
98 model: Option<String>,
100 session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
105 permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
111}
112
113impl Agent {
114 pub fn builder() -> AgentBuilder {
116 AgentBuilder::new()
117 }
118
119 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 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 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 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 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 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 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 drain.abort();
219 result
220 }
221
222 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 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 pub fn run_stream_session(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
250 self.run_stream_session_with_cancel(session, CancellationToken::new())
251 }
252
253 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 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 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 async fn execute_internal(
316 &self,
317 session: &mut Session,
318 event_tx: mpsc::Sender<AgentEvent>,
319 cancel_token: CancellationToken,
320 ) -> Result<(), AgentError> {
321 self.reexecute_approved_tool_if_pending(session, &event_tx)
330 .await;
331
332 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 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 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 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 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 pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
480 self.inner.storage()
481 }
482
483 pub fn persistence(&self) -> &Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
485 self.inner.persistence()
486 }
487
488 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 pub async fn resume(&self, session: &mut Session) -> Result<(), AgentError> {
577 self.run_session(session).await
578 }
579
580 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 pub fn resume_stream(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
594 self.run_stream_session(session)
595 }
596
597 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 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 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 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 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 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 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#[derive(Debug)]
718pub struct AnswerOutcome {
719 pub session: Session,
722 pub response: String,
724 pub plan_mode_transition: Option<PlanModeTransition>,
727 pub permission_grants: Vec<(bamboo_tools::permission::PermissionType, String)>,
732}
733
734#[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 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
773fn 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
787fn 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(!agent.answer_child_approval("child-x", "req-unknown", true));
1433
1434 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 assert!(!agent.answer_child_approval("child-x", "req-1", true));
1465 }
1466}