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, Message, MessageContent, PendingQuestion, Role, Session,
71 TokenBudgetUsage, TokenUsage,
72};
73pub use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
74pub use bamboo_engine::session_app::respond::PlanModeTransition;
75pub use bamboo_engine::ExecuteRequest;
76pub use bamboo_llm::LLMProvider;
77pub use bamboo_mcp::manager::McpServerManager;
78pub use bamboo_mcp::McpServerConfig;
79pub use bamboo_storage::SessionIndexEntry;
80pub use bamboo_tools::permission::{PermissionChecker, PermissionType};
81pub use bamboo_tools::{BuiltinToolExecutor, BuiltinToolExecutorBuilder, ToolOutputManager};
82
83const EVENT_CHANNEL_CAPACITY: usize = 256;
85
86#[derive(Clone)]
91pub struct Agent {
92 inner: bamboo_engine::Agent,
93 system_prompt: Option<String>,
96 model: Option<String>,
98 session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
103 permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
109}
110
111impl Agent {
112 pub fn builder() -> AgentBuilder {
114 AgentBuilder::new()
115 }
116
117 pub fn from_runtime(inner: bamboo_engine::Agent) -> Self {
120 Self {
121 inner,
122 system_prompt: None,
123 model: None,
124 session_store: None,
125 permission_checker: None,
126 }
127 }
128
129 pub(crate) fn from_runtime_with_config(
132 inner: bamboo_engine::Agent,
133 system_prompt: Option<String>,
134 model: Option<String>,
135 session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
136 permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
137 ) -> Self {
138 Self {
139 inner,
140 system_prompt,
141 model,
142 session_store,
143 permission_checker,
144 }
145 }
146
147 pub async fn run(
158 &self,
159 session: &mut Session,
160 input: impl Into<String>,
161 ) -> Result<(), AgentError> {
162 session.add_message(Message::user(input.into()));
163 self.run_session(session).await
164 }
165
166 pub async fn run_with_cancel(
170 &self,
171 session: &mut Session,
172 input: impl Into<String>,
173 cancel_token: CancellationToken,
174 ) -> Result<(), AgentError> {
175 session.add_message(Message::user(input.into()));
176 self.run_session_with_cancel(session, cancel_token).await
177 }
178
179 pub async fn run_session(&self, session: &mut Session) -> Result<(), AgentError> {
194 self.run_session_with_cancel(session, CancellationToken::new())
195 .await
196 }
197
198 pub async fn run_session_with_cancel(
202 &self,
203 session: &mut Session,
204 cancel_token: CancellationToken,
205 ) -> Result<(), AgentError> {
206 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
207
208 let drain = tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
210
211 let result = self.execute_internal(session, event_tx, cancel_token).await;
212
213 drain.abort();
217 result
218 }
219
220 pub fn run_stream(
224 &self,
225 mut session: Session,
226 input: impl Into<String>,
227 ) -> mpsc::Receiver<AgentEvent> {
228 session.add_message(Message::user(input.into()));
229 self.run_stream_session(session)
230 }
231
232 pub fn run_stream_cancellable(
237 &self,
238 mut session: Session,
239 input: impl Into<String>,
240 ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
241 session.add_message(Message::user(input.into()));
242 self.run_stream_session_cancellable(session)
243 }
244
245 pub fn run_stream_session(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
248 self.run_stream_session_with_cancel(session, CancellationToken::new())
249 }
250
251 pub fn run_stream_session_cancellable(
254 &self,
255 session: Session,
256 ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
257 let cancel_token = CancellationToken::new();
258 let rx = self.run_stream_session_with_cancel(session, cancel_token.clone());
259 (rx, cancel_token)
260 }
261
262 pub fn run_stream_session_with_cancel(
266 &self,
267 mut session: Session,
268 cancel_token: CancellationToken,
269 ) -> mpsc::Receiver<AgentEvent> {
270 let (event_tx, event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
271 let agent = self.clone();
272
273 tokio::spawn(async move {
274 if let Err(error) = agent
275 .execute_internal(&mut session, event_tx, cancel_token)
276 .await
277 {
278 tracing::warn!("Agent::run_stream execution failed: {error}");
279 }
280 });
281
282 event_rx
283 }
284
285 pub async fn execute(
303 &self,
304 session: &mut Session,
305 request: ExecuteRequest,
306 ) -> Result<(), AgentError> {
307 self.inner.execute(session, request).await
308 }
309
310 async fn execute_internal(
314 &self,
315 session: &mut Session,
316 event_tx: mpsc::Sender<AgentEvent>,
317 cancel_token: CancellationToken,
318 ) -> Result<(), AgentError> {
319 self.reexecute_approved_tool_if_pending(session, &event_tx)
328 .await;
329
330 bamboo_engine::session_app::execution_prep::prepare_session_for_execution(
337 session,
338 self.system_prompt.as_deref(),
339 self.model.as_deref(),
340 );
341
342 let initial_message = session
345 .messages
346 .iter()
347 .rev()
348 .find(|m| matches!(m.role, Role::User))
349 .map(|m| m.content.clone())
350 .unwrap_or_default();
351
352 let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token);
356 if let Some(model) = self.model.clone() {
357 builder = builder.model(model);
358 }
359
360 self.inner.execute(session, builder.build()).await
361 }
362
363 async fn reexecute_approved_tool_if_pending(
385 &self,
386 session: &mut Session,
387 event_tx: &mpsc::Sender<AgentEvent>,
388 ) {
389 let Some(tool_call_id) = session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY) else {
390 return;
391 };
392
393 let Some(tool_call) = find_pending_tool_call(session, &tool_call_id) else {
394 tracing::warn!(
395 session_id = %session.id,
396 tool_call_id = %tool_call_id,
397 "Permission re-exec marker set but tool call not found in history"
398 );
399 return;
400 };
401
402 let executor = self.inner.default_tools();
403 let tool_name = tool_call.function.name.clone();
404 let is_mutating = bamboo_tools::orchestrator::classify_tool(&tool_name)
405 == bamboo_tools::orchestrator::ToolMutability::Mutating;
406
407 let mut emitter = bamboo_tools::ToolEmitter::new(&tool_call.id, &tool_name, is_mutating);
412 emitter.set_auto_approved(true);
413 let _ = event_tx
414 .send(emitter.begin().clone().into_agent_event())
415 .await;
416
417 let exec_result = {
418 let ctx = bamboo_agent_core::tools::ToolExecutionContext {
419 session_id: Some(session.id.as_str()),
420 tool_call_id: tool_call_id.as_str(),
421 event_tx: Some(event_tx),
422 available_tool_schemas: None,
423 bypass_permissions: false,
424 can_async_resume: false,
425 bash_completion_sink: None,
426 pre_parsed_args: None,
427 };
428 executor.execute_with_context(&tool_call, ctx).await
429 };
430
431 let (content, success) = match exec_result {
432 Ok(tool_result) => {
433 let _ = event_tx
434 .send(
435 emitter
436 .finish(Some("Re-executed after approval".to_string()))
437 .clone()
438 .into_agent_event(),
439 )
440 .await;
441 let _ = event_tx
442 .send(AgentEvent::ToolComplete {
443 tool_call_id: tool_call.id.clone(),
444 result: tool_result.clone(),
445 })
446 .await;
447 (tool_result.result, tool_result.success)
448 }
449 Err(error) => {
450 let message = format!("Tool re-execution after approval failed: {error}");
451 let _ = event_tx
452 .send(emitter.error(message.clone()).clone().into_agent_event())
453 .await;
454 (message, false)
455 }
456 };
457
458 tracing::info!(
459 session_id = %session.id,
460 tool_name = %tool_name,
461 tool_call_id = %tool_call_id,
462 success,
463 "Re-executed approved tool after permission grant"
464 );
465 apply_tool_result(session, &tool_call_id, content, success);
466
467 if let Err(error) = self.persistence().save_runtime_session(session).await {
468 tracing::warn!(
469 session_id = %session.id,
470 %error,
471 "Failed to persist session after tool re-execution (loop's own save will retry)"
472 );
473 }
474 }
475
476 pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
478 self.inner.storage()
479 }
480
481 pub fn persistence(&self) -> &Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
483 self.inner.persistence()
484 }
485
486 pub async fn answer(
535 &self,
536 session_id: impl Into<String>,
537 response: impl Into<String>,
538 ) -> Result<AnswerOutcome, SdkError> {
539 let input = RespondInput {
540 session_id: session_id.into(),
541 user_response: response.into(),
542 model: None,
543 model_ref: None,
544 provider: None,
545 reasoning_effort: None,
546 };
547 let (session, response, plan_mode_transition, permission_grants) =
548 submit_pending_response(self, input).await?;
549
550 if let Some(checker) = &self.permission_checker {
551 if let Some(request_id) = session.metadata.get(PERMISSION_REEXECUTE_METADATA_KEY) {
552 for (perm_type, resource) in &permission_grants {
553 checker.grant_once(&session.id, request_id, *perm_type, resource.clone());
554 }
555 }
556 }
557
558 Ok(AnswerOutcome {
559 session,
560 response,
561 plan_mode_transition,
562 permission_grants,
563 })
564 }
565
566 pub async fn resume(&self, session: &mut Session) -> Result<(), AgentError> {
575 self.run_session(session).await
576 }
577
578 pub async fn resume_with_cancel(
581 &self,
582 session: &mut Session,
583 cancel_token: CancellationToken,
584 ) -> Result<(), AgentError> {
585 self.run_session_with_cancel(session, cancel_token).await
586 }
587
588 pub fn resume_stream(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
592 self.run_stream_session(session)
593 }
594
595 pub fn resume_stream_cancellable(
598 &self,
599 session: Session,
600 ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
601 self.run_stream_session_cancellable(session)
602 }
603
604 pub async fn answer_and_resume_stream(
608 &self,
609 session_id: impl Into<String>,
610 response: impl Into<String>,
611 ) -> Result<mpsc::Receiver<AgentEvent>, SdkError> {
612 let outcome = self.answer(session_id, response).await?;
613 Ok(self.resume_stream(outcome.session))
614 }
615
616 pub fn answer_child_approval(
653 &self,
654 child_session_id: impl AsRef<str>,
655 request_id: impl AsRef<str>,
656 approved: bool,
657 ) -> bool {
658 bamboo_engine::external_agents::live::deliver_approval_checked(
659 None,
660 child_session_id.as_ref(),
661 request_id.as_ref(),
662 approved,
663 )
664 }
665
666 pub async fn list_sessions(&self) -> Result<Vec<bamboo_storage::SessionIndexEntry>, SdkError> {
677 let store = self.session_store.as_ref().ok_or_else(|| {
678 SdkError::Unsupported(
679 "list_sessions requires an Agent built via with_defaults_for_data_dir".to_string(),
680 )
681 })?;
682 Ok(store.list_index_entries().await)
683 }
684
685 pub async fn get_session(&self, session_id: &str) -> Result<Option<Session>, SdkError> {
688 self.storage()
689 .load_session(session_id)
690 .await
691 .map_err(SdkError::Io)
692 }
693
694 pub async fn session_history(&self, session_id: &str) -> Result<Vec<Message>, SdkError> {
697 self.get_session(session_id)
698 .await?
699 .map(|session| session.messages)
700 .ok_or_else(|| SdkError::SessionNotFound(session_id.to_string()))
701 }
702
703 pub async fn delete_session(&self, session_id: &str) -> Result<bool, SdkError> {
705 self.storage()
706 .delete_session(session_id)
707 .await
708 .map_err(SdkError::Io)
709 }
710}
711
712#[derive(Debug)]
716pub struct AnswerOutcome {
717 pub session: Session,
720 pub response: String,
722 pub plan_mode_transition: Option<PlanModeTransition>,
725 pub permission_grants: Vec<(bamboo_tools::permission::PermissionType, String)>,
730}
731
732#[async_trait]
739impl SessionAccess for Agent {
740 async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
741 self.storage()
742 .load_session(id)
743 .await
744 .map_err(|e| SessionLoadError::StorageError(e.to_string()))
745 }
746
747 async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
748 match SessionAccess::load_session(self, id).await? {
749 Some(session) => Ok(session),
750 None => Ok(Session::new(id.to_string(), model.to_string())),
751 }
752 }
753
754 async fn load_merged(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
755 SessionAccess::load_session(self, id).await
757 }
758
759 async fn save_session(&self, session: &mut Session) -> Result<(), SessionSaveError> {
760 self.persistence()
761 .save_runtime_session(session)
762 .await
763 .map_err(|e| SessionSaveError::StorageError(e.to_string()))
764 }
765
766 async fn save_and_cache(&self, session: &mut Session) -> Result<(), SessionSaveError> {
767 SessionAccess::save_session(self, session).await
768 }
769}
770
771fn find_pending_tool_call(
774 session: &Session,
775 tool_call_id: &str,
776) -> Option<bamboo_agent_core::tools::ToolCall> {
777 session.messages.iter().find_map(|message| {
778 message
779 .tool_calls
780 .as_ref()
781 .and_then(|calls| calls.iter().find(|call| call.id == tool_call_id).cloned())
782 })
783}
784
785fn apply_tool_result(session: &mut Session, tool_call_id: &str, content: String, success: bool) {
788 for message in &mut session.messages {
789 if message.tool_call_id.as_deref() == Some(tool_call_id) {
790 message.content = content;
791 message.tool_success = Some(success);
792 return;
793 }
794 }
795}
796
797#[cfg(test)]
798mod approval_and_session_tests {
799 use super::*;
800 use bamboo_tools::permission::{
801 PermissionChecker, PermissionContext, PermissionError, PermissionMode, PermissionType,
802 };
803 use std::sync::Mutex as StdMutex;
804
805 async fn build_test_agent(data_dir: std::path::PathBuf) -> Agent {
809 let config_json = r#"{
810 "provider": "anthropic",
811 "providers": {
812 "anthropic": { "api_key": "test-key", "model": "claude-test" }
813 }
814 }"#;
815 std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
816
817 AgentBuilder::new()
818 .model("claude-test")
819 .instruction("test agent")
820 .with_defaults_for_data_dir(data_dir)
821 .await
822 .expect("defaults should assemble")
823 .build()
824 .expect("agent should build")
825 }
826
827 fn seed_session_with_pending_question(
828 session_id: &str,
829 options: Vec<String>,
830 allow_custom: bool,
831 ) -> Session {
832 let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
833 session.set_pending_question(
834 "call-1".to_string(),
835 "ConclusionWithOptions".to_string(),
836 "Pick one".to_string(),
837 options,
838 allow_custom,
839 );
840 session
841 }
842
843 #[tokio::test]
844 async fn answer_resolves_pending_question_and_persists() {
845 let tmp = tempfile::tempdir().expect("tempdir");
846 let agent = build_test_agent(tmp.path().to_path_buf()).await;
847
848 let session = seed_session_with_pending_question(
849 "sess-answer-ok",
850 vec!["A".to_string(), "B".to_string()],
851 false,
852 );
853 agent
854 .storage()
855 .save_session(&session)
856 .await
857 .expect("seed session");
858
859 let outcome = agent
860 .answer("sess-answer-ok", "A")
861 .await
862 .expect("answer should succeed");
863 assert_eq!(outcome.response, "A");
864 assert!(outcome.session.pending_question.is_none());
865 assert!(outcome.permission_grants.is_empty());
866
867 let reloaded = agent
869 .storage()
870 .load_session("sess-answer-ok")
871 .await
872 .expect("load")
873 .expect("present");
874 assert!(reloaded.pending_question.is_none());
875 assert!(reloaded
876 .messages
877 .iter()
878 .any(|m| m.tool_call_id.as_deref() == Some("call-1")
879 && m.content.contains("Selected response: A")));
880 }
881
882 #[tokio::test]
883 async fn answer_rejects_response_outside_fixed_options() {
884 let tmp = tempfile::tempdir().expect("tempdir");
885 let agent = build_test_agent(tmp.path().to_path_buf()).await;
886
887 let session = seed_session_with_pending_question(
888 "sess-answer-invalid",
889 vec!["A".to_string(), "B".to_string()],
890 false,
891 );
892 agent
893 .storage()
894 .save_session(&session)
895 .await
896 .expect("seed session");
897
898 let error = agent
899 .answer("sess-answer-invalid", "not-an-option")
900 .await
901 .expect_err("response outside options should be rejected");
902 assert!(matches!(error, SdkError::InvalidResponse(_)));
903 }
904
905 #[tokio::test]
906 async fn answer_errors_when_no_pending_question() {
907 let tmp = tempfile::tempdir().expect("tempdir");
908 let agent = build_test_agent(tmp.path().to_path_buf()).await;
909
910 let session = Session::new("sess-no-pending".to_string(), "claude-test".to_string());
911 agent
912 .storage()
913 .save_session(&session)
914 .await
915 .expect("seed session");
916
917 let error = agent
918 .answer("sess-no-pending", "anything")
919 .await
920 .expect_err("no pending question should error");
921 assert!(matches!(error, SdkError::NoPendingQuestion));
922 }
923
924 #[tokio::test]
925 async fn answer_errors_when_session_missing() {
926 let tmp = tempfile::tempdir().expect("tempdir");
927 let agent = build_test_agent(tmp.path().to_path_buf()).await;
928
929 let error = agent
930 .answer("does-not-exist", "anything")
931 .await
932 .expect_err("missing session should error");
933 assert!(matches!(error, SdkError::SessionNotFound(id) if id == "does-not-exist"));
934 }
935
936 #[tokio::test]
937 async fn session_ergonomics_list_get_history_delete_round_trip() {
938 let tmp = tempfile::tempdir().expect("tempdir");
939 let agent = build_test_agent(tmp.path().to_path_buf()).await;
940
941 let mut session_a = Session::new("sess-a".to_string(), "claude-test".to_string());
942 session_a.add_message(Message::user("hello"));
943 agent
944 .storage()
945 .save_session(&session_a)
946 .await
947 .expect("save a");
948
949 let session_b = Session::new("sess-b".to_string(), "claude-test".to_string());
950 agent
951 .storage()
952 .save_session(&session_b)
953 .await
954 .expect("save b");
955
956 let listed = agent.list_sessions().await.expect("list_sessions");
957 let ids: Vec<&str> = listed.iter().map(|entry| entry.id.as_str()).collect();
958 assert!(ids.contains(&"sess-a"));
959 assert!(ids.contains(&"sess-b"));
960
961 let history = agent
962 .session_history("sess-a")
963 .await
964 .expect("session_history");
965 assert_eq!(history.len(), 1);
966 assert_eq!(history[0].content, "hello");
967
968 let missing_history = agent.session_history("does-not-exist").await;
969 assert!(matches!(
970 missing_history,
971 Err(SdkError::SessionNotFound(id)) if id == "does-not-exist"
972 ));
973
974 let deleted = agent.delete_session("sess-a").await.expect("delete");
975 assert!(deleted);
976 assert!(agent
977 .get_session("sess-a")
978 .await
979 .expect("get_session")
980 .is_none());
981 }
982
983 #[derive(Default)]
989 struct RecordingPermissionChecker {
990 grants: StdMutex<Vec<(String, String, PermissionType, String)>>,
991 }
992
993 #[async_trait]
994 impl PermissionChecker for RecordingPermissionChecker {
995 async fn needs_confirmation(&self, _perm_type: PermissionType, _resource: &str) -> bool {
996 false
997 }
998
999 async fn request_confirmation(
1000 &self,
1001 _ctx: PermissionContext,
1002 ) -> Result<bool, PermissionError> {
1003 Ok(true)
1004 }
1005
1006 fn grant_session_permission(&self, perm_type: PermissionType, resource: String) {
1007 panic!("legacy unscoped grant used: {perm_type:?} {resource}");
1008 }
1009
1010 fn grant_once(
1011 &self,
1012 session_id: &str,
1013 request_id: &str,
1014 perm_type: PermissionType,
1015 resource: String,
1016 ) {
1017 self.grants.lock().unwrap().push((
1018 session_id.to_string(),
1019 request_id.to_string(),
1020 perm_type,
1021 resource,
1022 ));
1023 }
1024
1025 fn set_permission_mode(&self, _mode: PermissionMode) {}
1026 }
1027
1028 #[tokio::test]
1029 async fn answer_applies_permission_grants_to_configured_checker() {
1030 let tmp = tempfile::tempdir().expect("tempdir");
1031 let config_json = r#"{
1032 "provider": "anthropic",
1033 "providers": {
1034 "anthropic": { "api_key": "test-key", "model": "claude-test" }
1035 }
1036 }"#;
1037 std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
1038
1039 let checker = Arc::new(RecordingPermissionChecker::default());
1040 let agent = AgentBuilder::new()
1041 .model("claude-test")
1042 .permission_checker(checker.clone())
1043 .with_defaults_for_data_dir(tmp.path().to_path_buf())
1044 .await
1045 .expect("defaults should assemble")
1046 .build()
1047 .expect("agent should build");
1048
1049 let mut session = Session::new("sess-permission".to_string(), "claude-test".to_string());
1054 session.set_pending_question(
1055 "call-perm-1".to_string(),
1056 "Write".to_string(),
1057 "Permission required".to_string(),
1058 vec!["Approve".to_string(), "Deny".to_string()],
1059 false,
1060 );
1061 session.add_message(Message::tool_result(
1062 "call-perm-1",
1063 serde_json::json!({
1064 "status": "awaiting_permission_approval",
1065 "question": "Permission required",
1066 "permission_type": "write_file",
1067 "resource": "/tmp/example.txt",
1068 "options": ["Approve", "Deny"],
1069 "allow_custom": false,
1070 })
1071 .to_string(),
1072 ));
1073 agent
1074 .storage()
1075 .save_session(&session)
1076 .await
1077 .expect("seed session");
1078
1079 let outcome = agent
1080 .answer("sess-permission", "Approve")
1081 .await
1082 .expect("answer should succeed");
1083 assert_eq!(
1084 outcome.permission_grants,
1085 vec![(PermissionType::WriteFile, "/tmp/example.txt".to_string())]
1086 );
1087
1088 let recorded = checker.grants.lock().unwrap();
1089 assert_eq!(
1090 *recorded,
1091 vec![(
1092 "sess-permission".to_string(),
1093 "call-perm-1".to_string(),
1094 PermissionType::WriteFile,
1095 "/tmp/example.txt".to_string()
1096 )]
1097 );
1098 }
1099
1100 #[tokio::test]
1101 async fn list_sessions_unsupported_without_defaults_for_data_dir() {
1102 let tmp = tempfile::tempdir().expect("tempdir");
1107 let agent = build_test_agent(tmp.path().to_path_buf()).await;
1108 let bare = Agent::from_runtime_with_config(
1109 agent.inner.clone(),
1112 None,
1113 None,
1114 None,
1115 None,
1116 );
1117 let result = bare.list_sessions().await;
1118 assert!(matches!(result, Err(SdkError::Unsupported(_))));
1119 }
1120}
1121
1122#[cfg(test)]
1123mod reexecute_and_child_approval_tests {
1124 use super::*;
1125 use bamboo_agent_core::tools::{FunctionCall, Tool, ToolCall, ToolCtx, ToolError, ToolOutcome};
1126 use std::sync::atomic::{AtomicUsize, Ordering};
1127
1128 struct RealOutputTool {
1133 calls: AtomicUsize,
1134 }
1135
1136 impl RealOutputTool {
1137 fn new() -> Self {
1138 Self {
1139 calls: AtomicUsize::new(0),
1140 }
1141 }
1142 }
1143
1144 #[async_trait]
1145 impl Tool for RealOutputTool {
1146 fn name(&self) -> &str {
1147 "real_output_tool"
1148 }
1149
1150 fn description(&self) -> &str {
1151 "test-only tool that returns a distinctive real result"
1152 }
1153
1154 fn parameters_schema(&self) -> serde_json::Value {
1155 serde_json::json!({ "type": "object", "properties": {} })
1156 }
1157
1158 async fn invoke(
1159 &self,
1160 _args: serde_json::Value,
1161 _ctx: ToolCtx,
1162 ) -> Result<ToolOutcome, ToolError> {
1163 let n = self.calls.fetch_add(1, Ordering::SeqCst);
1164 Ok(ToolOutcome::Completed(
1165 bamboo_agent_core::tools::ToolResult::text(true, format!("REAL TOOL OUTPUT #{n}")),
1166 ))
1167 }
1168 }
1169
1170 async fn build_test_agent_with_tool(
1171 data_dir: std::path::PathBuf,
1172 tool: Arc<RealOutputTool>,
1173 ) -> Agent {
1174 let config_json = r#"{
1175 "provider": "anthropic",
1176 "providers": {
1177 "anthropic": { "api_key": "test-key", "model": "claude-test" }
1178 }
1179 }"#;
1180 std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
1181
1182 AgentBuilder::new()
1183 .model("claude-test")
1184 .instruction("test agent")
1185 .tool_shared(tool)
1186 .with_defaults_for_data_dir(data_dir)
1187 .await
1188 .expect("defaults should assemble")
1189 .build()
1190 .expect("agent should build")
1191 }
1192
1193 fn seed_gated_tool_session(session_id: &str, tool_call_id: &str) -> Session {
1198 let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
1199 session.add_message(Message::assistant(
1200 "",
1201 Some(vec![ToolCall {
1202 id: tool_call_id.to_string(),
1203 tool_type: "function".to_string(),
1204 function: FunctionCall {
1205 name: "real_output_tool".to_string(),
1206 arguments: "{}".to_string(),
1207 },
1208 }]),
1209 ));
1210 session.set_pending_question(
1211 tool_call_id.to_string(),
1212 "real_output_tool".to_string(),
1213 "Permission required".to_string(),
1214 vec!["Approve".to_string(), "Deny".to_string()],
1215 false,
1216 );
1217 session.add_message(Message::tool_result(
1218 tool_call_id,
1219 serde_json::json!({
1220 "status": "awaiting_permission_approval",
1221 "question": "Permission required",
1222 "permission_type": "write_file",
1223 "resource": "/tmp/example.txt",
1224 "options": ["Approve", "Deny"],
1225 "allow_custom": false,
1226 })
1227 .to_string(),
1228 ));
1229 session
1230 }
1231
1232 #[tokio::test]
1233 async fn approve_marks_session_for_reexecution() {
1234 let tmp = tempfile::tempdir().expect("tempdir");
1235 let tool = Arc::new(RealOutputTool::new());
1236 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1237
1238 let session = seed_gated_tool_session("sess-mark", "call-mark-1");
1239 agent
1240 .storage()
1241 .save_session(&session)
1242 .await
1243 .expect("seed session");
1244
1245 let outcome = agent
1246 .answer("sess-mark", "Approve")
1247 .await
1248 .expect("answer should succeed");
1249
1250 assert_eq!(
1251 outcome
1252 .session
1253 .metadata
1254 .get(PERMISSION_REEXECUTE_METADATA_KEY)
1255 .map(String::as_str),
1256 Some("call-mark-1"),
1257 "approving a permission prompt must stamp the re-exec marker"
1258 );
1259 }
1260
1261 #[tokio::test]
1262 async fn deny_does_not_mark_session_for_reexecution() {
1263 let tmp = tempfile::tempdir().expect("tempdir");
1264 let tool = Arc::new(RealOutputTool::new());
1265 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1266
1267 let session = seed_gated_tool_session("sess-deny", "call-deny-1");
1268 agent
1269 .storage()
1270 .save_session(&session)
1271 .await
1272 .expect("seed session");
1273
1274 let outcome = agent
1275 .answer("sess-deny", "Deny")
1276 .await
1277 .expect("answer should succeed");
1278
1279 assert!(outcome.permission_grants.is_empty());
1280 assert!(!outcome
1281 .session
1282 .metadata
1283 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
1284 let tool_message = outcome
1287 .session
1288 .messages
1289 .iter()
1290 .find(|m| m.tool_call_id.as_deref() == Some("call-deny-1"))
1291 .expect("tool result message present");
1292 assert_eq!(tool_message.content, "Selected response: Deny");
1293 }
1294
1295 #[tokio::test]
1296 async fn approve_then_reexecute_runs_real_tool_and_overwrites_placeholder() {
1297 let tmp = tempfile::tempdir().expect("tempdir");
1298 let tool = Arc::new(RealOutputTool::new());
1299 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1300
1301 let session = seed_gated_tool_session("sess-reexec", "call-reexec-1");
1302 agent
1303 .storage()
1304 .save_session(&session)
1305 .await
1306 .expect("seed session");
1307
1308 let outcome = agent
1309 .answer("sess-reexec", "Approve")
1310 .await
1311 .expect("answer should succeed");
1312 let mut session = outcome.session;
1313
1314 let placeholder = session
1317 .messages
1318 .iter()
1319 .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1320 .expect("tool result message present");
1321 assert_eq!(placeholder.content, "Selected response: Approve");
1322 assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1323
1324 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1327 agent
1328 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1329 .await;
1330 drop(event_tx);
1331
1332 assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
1335 let real_result = session
1336 .messages
1337 .iter()
1338 .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1339 .expect("tool result message present");
1340 assert_eq!(real_result.content, "REAL TOOL OUTPUT #0");
1341 assert_eq!(real_result.tool_success, Some(true));
1342 assert!(
1343 !session
1344 .metadata
1345 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
1346 "the marker must be consumed (removed) after re-execution"
1347 );
1348
1349 let mut saw_tool_complete = false;
1352 while let Ok(event) = event_rx.try_recv() {
1353 if let AgentEvent::ToolComplete { tool_call_id, .. } = event {
1354 assert_eq!(tool_call_id, "call-reexec-1");
1355 saw_tool_complete = true;
1356 }
1357 }
1358 assert!(saw_tool_complete, "expected a ToolComplete event");
1359
1360 let reloaded = agent
1362 .storage()
1363 .load_session("sess-reexec")
1364 .await
1365 .expect("load")
1366 .expect("present");
1367 let reloaded_result = reloaded
1368 .messages
1369 .iter()
1370 .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1371 .expect("tool result message present");
1372 assert_eq!(reloaded_result.content, "REAL TOOL OUTPUT #0");
1373 }
1374
1375 #[tokio::test]
1376 async fn reexecute_is_noop_without_pending_marker() {
1377 let tmp = tempfile::tempdir().expect("tempdir");
1378 let tool = Arc::new(RealOutputTool::new());
1379 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1380
1381 let mut session = Session::new("sess-noop".to_string(), "claude-test".to_string());
1382 session.add_message(Message::user("hi"));
1383
1384 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
1385 agent
1386 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1387 .await;
1388
1389 assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1390 assert_eq!(session.messages.len(), 1);
1391 }
1392
1393 #[tokio::test]
1394 async fn reexecute_warns_and_clears_marker_when_tool_call_missing() {
1395 let tmp = tempfile::tempdir().expect("tempdir");
1396 let tool = Arc::new(RealOutputTool::new());
1397 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1398
1399 let mut session = Session::new("sess-missing".to_string(), "claude-test".to_string());
1400 session.metadata.insert(
1402 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1403 "ghost-call".to_string(),
1404 );
1405
1406 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1407 agent
1408 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1409 .await;
1410 drop(event_tx);
1411
1412 assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1413 assert!(event_rx.try_recv().is_err(), "no events should be emitted");
1414 assert!(
1415 !session
1416 .metadata
1417 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
1418 "the marker is removed even when the tool call can't be found, so a \
1419 missing/pruned call can't wedge every future execution"
1420 );
1421 }
1422
1423 #[tokio::test]
1424 async fn answer_child_approval_delivers_only_genuinely_pending_requests() {
1425 let tmp = tempfile::tempdir().expect("tempdir");
1426 let tool = Arc::new(RealOutputTool::new());
1427 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1428
1429 assert!(!agent.answer_child_approval("child-x", "req-unknown", true));
1431
1432 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1438 let _live_guard = bamboo_engine::external_agents::live::register("child-x", tx, 0, None);
1439 let (approval_event_tx, _approval_event_rx) = tokio::sync::mpsc::channel(4);
1440 bamboo_engine::external_agents::live::register_pending_approval_observed(
1441 None,
1442 "parent-x",
1443 "child-x",
1444 0,
1445 "req-1",
1446 "shell",
1447 "execute",
1448 "cargo test",
1449 approval_event_tx,
1450 );
1451
1452 assert!(agent.answer_child_approval("child-x", "req-1", true));
1453 match rx.try_recv() {
1454 Ok(bamboo_subagent::proto::ParentFrame::ApprovalReply { id, approved }) => {
1455 assert_eq!(id, "req-1");
1456 assert!(approved);
1457 }
1458 other => panic!("expected an ApprovalReply frame, got {other:?}"),
1459 }
1460
1461 assert!(!agent.answer_child_approval("child-x", "req-1", true));
1463 }
1464}