1mod builder;
44mod error;
45mod execute_request;
46mod tools;
47
48use std::sync::Arc;
49
50use async_trait::async_trait;
51pub use builder::AgentBuilder;
52pub use execute_request::ExecuteRequestBuilder;
53use tokio::sync::mpsc;
54
55use bamboo_engine::session_app::approval_replay::{
56 refresh_approval_replay_posture, ApprovalReplayDecision,
57};
58use bamboo_engine::session_app::errors::{SessionLoadError, SessionSaveError};
59use bamboo_engine::session_app::repository::SessionAccess;
60use bamboo_engine::session_app::respond::{
61 submit_pending_response, PERMISSION_REEXECUTE_METADATA_KEY,
62};
63use bamboo_engine::session_app::types::RespondInput;
64
65pub use tokio_util::sync::CancellationToken;
68pub use tools::{
69 builtin_tool_names, builtin_tool_specs, BuiltinTool, ToolSpec, CANONICAL_TOOL_NAMES,
70};
71
72pub use error::SdkError;
73
74pub use bamboo_agent_core::{
77 AgentError, AgentEvent, AgentHook, Message, MessageContent, PendingQuestion, Role, Session,
78 TokenBudgetUsage, TokenUsage,
79};
80pub use bamboo_domain::{
81 AgentHookPoint, HookPayload, HookResult, HookToolOutcome, SessionActivationDisposition,
82 SessionActivationError, SessionActivationPolicy, SessionActivationPort, SessionChildOutcome,
83 SessionInboxBacklog, SessionInboxClaim, SessionInboxError, SessionInboxLimits,
84 SessionInboxPort, SessionInboxReceipt, SessionMessageBody, SessionMessageContent,
85 SessionMessageEnvelope, SessionMessageId, SessionMessageKind, SessionMessageSource,
86 SessionProviderMessage, SessionRuntimeInstruction, TaskItem, TaskItemStatus, TaskList,
87};
88pub use bamboo_engine::session_app::respond::PlanModeTransition;
89pub use bamboo_engine::{
90 Agent as RuntimeAgent, AgentBuilder as RuntimeAgentBuilder, ExecuteRequest, HookRunner,
91 LifecycleHookEvent, LifecycleHookTestOutput, LifecycleScriptRunner, ScriptHook,
92 SessionActivationLaunch, SessionActivationReserveOutcome, SessionActivationRouter,
93 SessionActivationSpawner, SessionMessagingMetrics, SessionMessagingMetricsSnapshot,
94 SessionMessenger, SessionMessengerAdmission, SessionMessengerError, SessionMessengerReceipt,
95 SessionRunRegistration, SessionRunRegistrationError, ShellCommandHook, ShellHookEvent,
96};
97pub use bamboo_llm::LLMProvider;
98pub use bamboo_mcp::manager::McpServerManager;
99pub use bamboo_mcp::{McpServerConfig, StdioConfig, TransportConfig};
100pub use bamboo_storage::{FileSessionInbox, SessionIndexEntry};
101pub use bamboo_tools::permission::{PermissionChecker, PermissionMode, PermissionType};
102pub use bamboo_tools::{BuiltinToolExecutor, BuiltinToolExecutorBuilder, ToolOutputManager};
103
104const EVENT_CHANNEL_CAPACITY: usize = 256;
106
107#[derive(Clone)]
112pub struct Agent {
113 inner: bamboo_engine::Agent,
114 system_prompt: Option<String>,
117 model: Option<String>,
119 session_model: Option<String>,
122 project_id: Option<bamboo_domain::ProjectId>,
124 session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
129 permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
135 permission_mode: PermissionMode,
139}
140
141impl Agent {
142 pub fn builder() -> AgentBuilder {
144 AgentBuilder::new()
145 }
146
147 pub fn from_runtime(inner: bamboo_engine::Agent) -> Self {
150 Self {
151 inner,
152 system_prompt: None,
153 model: None,
154 session_model: None,
155 project_id: None,
156 session_store: None,
157 permission_checker: None,
158 permission_mode: PermissionMode::Default,
159 }
160 }
161
162 pub(crate) fn from_runtime_with_config(
165 inner: bamboo_engine::Agent,
166 system_prompt: Option<String>,
167 model: Option<String>,
168 session_model: Option<String>,
169 project_id: Option<bamboo_domain::ProjectId>,
170 session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
171 permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
172 permission_mode: PermissionMode,
173 ) -> Self {
174 Self {
175 inner,
176 system_prompt,
177 model,
178 session_model,
179 project_id,
180 session_store,
181 permission_checker,
182 permission_mode,
183 }
184 }
185
186 pub async fn run(
197 &self,
198 session: &mut Session,
199 input: impl Into<String>,
200 ) -> Result<(), AgentError> {
201 session.add_message(Message::user(input.into()));
202 self.run_session(session).await
203 }
204
205 pub async fn run_with_cancel(
209 &self,
210 session: &mut Session,
211 input: impl Into<String>,
212 cancel_token: CancellationToken,
213 ) -> Result<(), AgentError> {
214 session.add_message(Message::user(input.into()));
215 self.run_session_with_cancel(session, cancel_token).await
216 }
217
218 pub async fn run_session(&self, session: &mut Session) -> Result<(), AgentError> {
237 self.run_session_with_cancel(session, CancellationToken::new())
238 .await
239 }
240
241 pub async fn run_session_with_cancel(
245 &self,
246 session: &mut Session,
247 cancel_token: CancellationToken,
248 ) -> Result<(), AgentError> {
249 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
250
251 let drain = tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
253
254 let result = self.execute_internal(session, event_tx, cancel_token).await;
255
256 drain.abort();
260 result
261 }
262
263 pub fn run_stream(
267 &self,
268 mut session: Session,
269 input: impl Into<String>,
270 ) -> mpsc::Receiver<AgentEvent> {
271 session.add_message(Message::user(input.into()));
272 self.run_stream_session(session)
273 }
274
275 pub fn run_stream_cancellable(
280 &self,
281 mut session: Session,
282 input: impl Into<String>,
283 ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
284 session.add_message(Message::user(input.into()));
285 self.run_stream_session_cancellable(session)
286 }
287
288 pub fn run_stream_session(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
291 self.run_stream_session_with_cancel(session, CancellationToken::new())
292 }
293
294 pub fn run_stream_session_cancellable(
297 &self,
298 session: Session,
299 ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
300 let cancel_token = CancellationToken::new();
301 let rx = self.run_stream_session_with_cancel(session, cancel_token.clone());
302 (rx, cancel_token)
303 }
304
305 pub fn run_stream_session_with_cancel(
309 &self,
310 mut session: Session,
311 cancel_token: CancellationToken,
312 ) -> mpsc::Receiver<AgentEvent> {
313 let (event_tx, event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
314 let agent = self.clone();
315
316 tokio::spawn(async move {
317 let execution_tx = event_tx.clone();
323 if let Err(error) = agent
324 .execute_internal(&mut session, execution_tx, cancel_token)
325 .await
326 {
327 tracing::warn!("Agent::run_stream execution failed: {error}");
328 }
329 });
330
331 event_rx
332 }
333
334 pub async fn execute(
356 &self,
357 session: &mut Session,
358 request: ExecuteRequest,
359 ) -> Result<(), AgentError> {
360 self.inner.execute_direct(session, request).await
361 }
362
363 async fn execute_internal(
367 &self,
368 session: &mut Session,
369 event_tx: mpsc::Sender<AgentEvent>,
370 cancel_token: CancellationToken,
371 ) -> Result<(), AgentError> {
372 let direct_lease = self.inner.begin_direct_execution(&session.id).await?;
376 if session.project_id_meta().is_none() {
377 if let Some(project_id) = self.project_id.as_ref() {
378 session.set_project_id_meta(project_id.to_string());
379 }
380 }
381 self.reexecute_approved_tool_if_pending(session, &event_tx)
390 .await?;
391
392 bamboo_engine::session_app::execution_prep::prepare_session_for_execution(
399 session,
400 self.system_prompt.as_deref(),
401 self.model.as_deref(),
402 );
403
404 let initial_message = session
407 .messages
408 .iter()
409 .rev()
410 .find(|m| matches!(m.role, Role::User))
411 .map(|m| m.content.clone())
412 .unwrap_or_default();
413
414 let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token);
418 if let Some(model) = self.model.clone() {
419 builder = builder.model(model);
420 }
421
422 self.inner
423 .execute_direct_registered(session, builder.build(), direct_lease)
424 .await
425 }
426
427 async fn reexecute_approved_tool_if_pending(
449 &self,
450 session: &mut Session,
451 event_tx: &mpsc::Sender<AgentEvent>,
452 ) -> Result<(), AgentError> {
453 let Some(tool_call_id) = session
454 .metadata
455 .get(PERMISSION_REEXECUTE_METADATA_KEY)
456 .cloned()
457 else {
458 return Ok(());
459 };
460
461 let Some(tool_call) = find_pending_tool_call(session, &tool_call_id) else {
462 session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
463 tracing::warn!(
464 session_id = %session.id,
465 tool_call_id = %tool_call_id,
466 "Permission re-exec marker set but tool call not found in history"
467 );
468 return Ok(());
469 };
470
471 let tool_name = tool_call.function.name.clone();
472 let decision = refresh_approval_replay_posture(
473 self.storage().as_ref(),
474 session,
475 self.permission_mode,
476 &tool_name,
477 )
478 .await?;
479
480 let flags = match decision {
481 ApprovalReplayDecision::Execute(flags) => flags,
482 ApprovalReplayDecision::BlockedByPlan(_) => {
483 session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
484 apply_tool_result(
485 session,
486 &tool_call_id,
487 format!(
488 "Plan mode blocked approved mutating tool '{tool_name}'; the stale approval was not executed"
489 ),
490 false,
491 );
492 if let Err(error) = self.persistence().save_runtime_session(session).await {
493 tracing::warn!(
494 session_id = %session.id,
495 %error,
496 "Failed to persist Plan-blocked approval replay (loop's own save will retry)"
497 );
498 }
499 return Ok(());
500 }
501 };
502 session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
503
504 let executor = self.inner.default_tools();
505 let is_mutating = bamboo_tools::orchestrator::classify_tool(&tool_name)
506 == bamboo_tools::orchestrator::ToolMutability::Mutating;
507
508 let mut emitter = bamboo_tools::ToolEmitter::new(&tool_call.id, &tool_name, is_mutating);
513 emitter.set_auto_approved(true);
514 let _ = event_tx
515 .send(emitter.begin().clone().into_agent_event())
516 .await;
517
518 let exec_result = {
519 let ctx = bamboo_agent_core::tools::ToolExecutionContext {
520 session_id: Some(session.id.as_str()),
521 tool_call_id: tool_call_id.as_str(),
522 event_tx: Some(event_tx),
523 available_tool_schemas: None,
524 bypass_permissions: flags.bypass_permissions,
525 auto_approve_permissions: flags.auto_approve_permissions,
526 plan_read_only: flags.plan_read_only,
527 can_async_resume: false,
528 bash_completion_sink: None,
529 pre_parsed_args: None,
530 };
531 executor.execute_with_context(&tool_call, ctx).await
532 };
533
534 let (content, success) = match exec_result {
535 Ok(tool_result) => {
536 let _ = event_tx
537 .send(
538 emitter
539 .finish(Some("Re-executed after approval".to_string()))
540 .clone()
541 .into_agent_event(),
542 )
543 .await;
544 let _ = event_tx
545 .send(AgentEvent::ToolComplete {
546 tool_call_id: tool_call.id.clone(),
547 result: tool_result.clone(),
548 })
549 .await;
550 (tool_result.result, tool_result.success)
551 }
552 Err(error) => {
553 let message = format!("Tool re-execution after approval failed: {error}");
554 let _ = event_tx
555 .send(emitter.error(message.clone()).clone().into_agent_event())
556 .await;
557 (message, false)
558 }
559 };
560
561 tracing::info!(
562 session_id = %session.id,
563 tool_name = %tool_name,
564 tool_call_id = %tool_call_id,
565 success,
566 "Re-executed approved tool after permission grant"
567 );
568 apply_tool_result(session, &tool_call_id, content, success);
569
570 if let Err(error) = self.persistence().save_runtime_session(session).await {
571 tracing::warn!(
572 session_id = %session.id,
573 %error,
574 "Failed to persist session after tool re-execution (loop's own save will retry)"
575 );
576 }
577 Ok(())
578 }
579
580 pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
582 self.inner.storage()
583 }
584
585 pub fn persistence(&self) -> &Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
587 self.inner.persistence()
588 }
589
590 pub fn session_messenger(&self) -> Option<&Arc<bamboo_engine::SessionMessenger>> {
594 self.inner.session_messenger()
595 }
596
597 pub fn session_inbox(&self) -> Option<&Arc<dyn bamboo_domain::SessionInboxPort>> {
599 self.inner.session_inbox()
600 }
601
602 pub fn activation_router(&self) -> Option<&Arc<bamboo_engine::SessionActivationRouter>> {
605 self.inner.activation_router()
606 }
607
608 pub async fn answer(
657 &self,
658 session_id: impl Into<String>,
659 response: impl Into<String>,
660 ) -> Result<AnswerOutcome, SdkError> {
661 let input = RespondInput {
662 session_id: session_id.into(),
663 user_response: response.into(),
664 model: None,
665 model_ref: None,
666 provider: None,
667 reasoning_effort: None,
668 };
669 let (session, response, plan_mode_transition, permission_grants) =
670 submit_pending_response(self, input).await?;
671
672 if let Some(checker) = &self.permission_checker {
673 if let Some(request_id) = session.metadata.get(PERMISSION_REEXECUTE_METADATA_KEY) {
674 for (perm_type, resource) in &permission_grants {
675 checker.grant_once(&session.id, request_id, *perm_type, resource.clone());
676 }
677 }
678 }
679
680 Ok(AnswerOutcome {
681 session,
682 response,
683 plan_mode_transition,
684 permission_grants,
685 })
686 }
687
688 pub async fn resume(&self, session: &mut Session) -> Result<(), AgentError> {
697 self.run_session(session).await
698 }
699
700 pub async fn resume_with_cancel(
703 &self,
704 session: &mut Session,
705 cancel_token: CancellationToken,
706 ) -> Result<(), AgentError> {
707 self.run_session_with_cancel(session, cancel_token).await
708 }
709
710 pub fn resume_stream(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
714 self.run_stream_session(session)
715 }
716
717 pub fn resume_stream_cancellable(
720 &self,
721 session: Session,
722 ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
723 self.run_stream_session_cancellable(session)
724 }
725
726 pub async fn answer_and_resume_stream(
730 &self,
731 session_id: impl Into<String>,
732 response: impl Into<String>,
733 ) -> Result<mpsc::Receiver<AgentEvent>, SdkError> {
734 let outcome = self.answer(session_id, response).await?;
735 Ok(self.resume_stream(outcome.session))
736 }
737
738 pub fn answer_child_approval(
775 &self,
776 child_session_id: impl AsRef<str>,
777 request_id: impl AsRef<str>,
778 approved: bool,
779 ) -> bool {
780 bamboo_engine::external_agents::live::deliver_approval_checked(
781 None,
782 child_session_id.as_ref(),
783 request_id.as_ref(),
784 approved,
785 )
786 }
787
788 pub fn new_session(&self, session_id: impl Into<String>) -> Result<Session, SdkError> {
801 let model = self
802 .model
803 .as_deref()
804 .or(self.session_model.as_deref())
805 .map(str::trim)
806 .filter(|model| !model.is_empty())
807 .ok_or(SdkError::ModelNotConfigured)?;
808 let mut session = Session::new(session_id.into(), model.to_string());
809 if let Some(project_id) = self.project_id.as_ref() {
810 session.set_project_id_meta(project_id.to_string());
811 }
812 Ok(session)
813 }
814
815 pub async fn list_sessions(&self) -> Result<Vec<bamboo_storage::SessionIndexEntry>, SdkError> {
822 let store = self.session_store.as_ref().ok_or_else(|| {
823 SdkError::Unsupported(
824 "list_sessions requires an Agent built via with_defaults_for_data_dir".to_string(),
825 )
826 })?;
827 Ok(store.list_index_entries().await)
828 }
829
830 pub async fn load_session(&self, session_id: &str) -> Result<Option<Session>, SdkError> {
833 SessionAccess::load_session(self, session_id)
834 .await
835 .map_err(SdkError::from)
836 }
837
838 pub async fn get_session(&self, session_id: &str) -> Result<Option<Session>, SdkError> {
840 self.load_session(session_id).await
841 }
842
843 pub async fn session_history(&self, session_id: &str) -> Result<Vec<Message>, SdkError> {
846 self.load_session(session_id)
847 .await?
848 .map(|session| session.messages)
849 .ok_or_else(|| SdkError::SessionNotFound(session_id.to_string()))
850 }
851
852 pub async fn delete_session(&self, session_id: &str) -> Result<bool, SdkError> {
854 self.storage()
855 .delete_session(session_id)
856 .await
857 .map_err(SdkError::Io)
858 }
859}
860
861#[derive(Debug)]
865pub struct AnswerOutcome {
866 pub session: Session,
869 pub response: String,
871 pub plan_mode_transition: Option<PlanModeTransition>,
874 pub permission_grants: Vec<(bamboo_tools::permission::PermissionType, String)>,
879}
880
881#[async_trait]
888impl SessionAccess for Agent {
889 async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
890 self.storage()
891 .load_session(id)
892 .await
893 .map_err(|e| SessionLoadError::StorageError(e.to_string()))
894 }
895
896 async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
897 match SessionAccess::load_session(self, id).await? {
898 Some(session) => Ok(session),
899 None => Ok(Session::new(id.to_string(), model.to_string())),
900 }
901 }
902
903 async fn load_merged(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
904 SessionAccess::load_session(self, id).await
906 }
907
908 async fn save_session(&self, session: &mut Session) -> Result<(), SessionSaveError> {
909 self.persistence()
910 .save_runtime_session(session)
911 .await
912 .map_err(|e| SessionSaveError::StorageError(e.to_string()))
913 }
914
915 async fn save_and_cache(&self, session: &mut Session) -> Result<(), SessionSaveError> {
916 SessionAccess::save_session(self, session).await
917 }
918}
919
920fn find_pending_tool_call(
923 session: &Session,
924 tool_call_id: &str,
925) -> Option<bamboo_agent_core::tools::ToolCall> {
926 session.messages.iter().find_map(|message| {
927 message
928 .tool_calls
929 .as_ref()
930 .and_then(|calls| calls.iter().find(|call| call.id == tool_call_id).cloned())
931 })
932}
933
934fn apply_tool_result(session: &mut Session, tool_call_id: &str, content: String, success: bool) {
937 for message in &mut session.messages {
938 if message.tool_call_id.as_deref() == Some(tool_call_id) {
939 message.content = content;
940 message.tool_success = Some(success);
941 return;
942 }
943 }
944}
945
946#[cfg(test)]
947mod approval_and_session_tests {
948 use super::*;
949 use bamboo_tools::permission::{
950 PermissionChecker, PermissionContext, PermissionError, PermissionMode, PermissionType,
951 };
952 use std::sync::Mutex as StdMutex;
953
954 async fn build_test_agent(data_dir: std::path::PathBuf) -> Agent {
958 let config_json = r#"{
959 "provider": "anthropic",
960 "providers": {
961 "anthropic": { "api_key": "test-key", "model": "claude-test" }
962 }
963 }"#;
964 std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
965
966 AgentBuilder::new()
967 .model("claude-test")
968 .instruction("test agent")
969 .with_defaults_for_data_dir(data_dir)
970 .await
971 .expect("defaults should assemble")
972 .build()
973 .expect("agent should build")
974 }
975
976 fn seed_session_with_pending_question(
977 session_id: &str,
978 options: Vec<String>,
979 allow_custom: bool,
980 ) -> Session {
981 let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
982 session.set_pending_question(
983 "call-1".to_string(),
984 "ConclusionWithOptions".to_string(),
985 "Pick one".to_string(),
986 options,
987 allow_custom,
988 );
989 session
990 }
991
992 #[tokio::test]
993 async fn answer_resolves_pending_question_and_persists() {
994 let tmp = tempfile::tempdir().expect("tempdir");
995 let agent = build_test_agent(tmp.path().to_path_buf()).await;
996
997 let session = seed_session_with_pending_question(
998 "sess-answer-ok",
999 vec!["A".to_string(), "B".to_string()],
1000 false,
1001 );
1002 agent
1003 .storage()
1004 .save_session(&session)
1005 .await
1006 .expect("seed session");
1007
1008 let outcome = agent
1009 .answer("sess-answer-ok", "A")
1010 .await
1011 .expect("answer should succeed");
1012 assert_eq!(outcome.response, "A");
1013 assert!(outcome.session.pending_question.is_none());
1014 assert!(outcome.permission_grants.is_empty());
1015
1016 let reloaded = agent
1018 .storage()
1019 .load_session("sess-answer-ok")
1020 .await
1021 .expect("load")
1022 .expect("present");
1023 assert!(reloaded.pending_question.is_none());
1024 assert!(reloaded
1025 .messages
1026 .iter()
1027 .any(|m| m.tool_call_id.as_deref() == Some("call-1")
1028 && m.content.contains("Selected response: A")));
1029 }
1030
1031 #[tokio::test]
1032 async fn answer_rejects_response_outside_fixed_options() {
1033 let tmp = tempfile::tempdir().expect("tempdir");
1034 let agent = build_test_agent(tmp.path().to_path_buf()).await;
1035
1036 let session = seed_session_with_pending_question(
1037 "sess-answer-invalid",
1038 vec!["A".to_string(), "B".to_string()],
1039 false,
1040 );
1041 agent
1042 .storage()
1043 .save_session(&session)
1044 .await
1045 .expect("seed session");
1046
1047 let error = agent
1048 .answer("sess-answer-invalid", "not-an-option")
1049 .await
1050 .expect_err("response outside options should be rejected");
1051 assert!(matches!(error, SdkError::InvalidResponse(_)));
1052 }
1053
1054 #[tokio::test]
1055 async fn answer_errors_when_no_pending_question() {
1056 let tmp = tempfile::tempdir().expect("tempdir");
1057 let agent = build_test_agent(tmp.path().to_path_buf()).await;
1058
1059 let session = Session::new("sess-no-pending".to_string(), "claude-test".to_string());
1060 agent
1061 .storage()
1062 .save_session(&session)
1063 .await
1064 .expect("seed session");
1065
1066 let error = agent
1067 .answer("sess-no-pending", "anything")
1068 .await
1069 .expect_err("no pending question should error");
1070 assert!(matches!(error, SdkError::NoPendingQuestion));
1071 }
1072
1073 #[tokio::test]
1074 async fn answer_errors_when_session_missing() {
1075 let tmp = tempfile::tempdir().expect("tempdir");
1076 let agent = build_test_agent(tmp.path().to_path_buf()).await;
1077
1078 let error = agent
1079 .answer("does-not-exist", "anything")
1080 .await
1081 .expect_err("missing session should error");
1082 assert!(matches!(error, SdkError::SessionNotFound(id) if id == "does-not-exist"));
1083 }
1084
1085 #[tokio::test]
1086 async fn session_ergonomics_list_get_history_delete_round_trip() {
1087 let tmp = tempfile::tempdir().expect("tempdir");
1088 let agent = build_test_agent(tmp.path().to_path_buf()).await;
1089
1090 let mut session_a = agent.new_session("sess-a").expect("new_session");
1091 assert_eq!(session_a.model, "claude-test");
1092 session_a.add_message(Message::user("hello"));
1093 agent
1094 .storage()
1095 .save_session(&session_a)
1096 .await
1097 .expect("save a");
1098
1099 let session_b = Session::new("sess-b".to_string(), "claude-test".to_string());
1100 agent
1101 .storage()
1102 .save_session(&session_b)
1103 .await
1104 .expect("save b");
1105
1106 let listed = agent.list_sessions().await.expect("list_sessions");
1107 let ids: Vec<&str> = listed.iter().map(|entry| entry.id.as_str()).collect();
1108 assert!(ids.contains(&"sess-a"));
1109 assert!(ids.contains(&"sess-b"));
1110
1111 let history = agent
1112 .session_history("sess-a")
1113 .await
1114 .expect("session_history");
1115 assert_eq!(history.len(), 1);
1116 assert_eq!(history[0].content, "hello");
1117
1118 let missing_history = agent.session_history("does-not-exist").await;
1119 assert!(matches!(
1120 missing_history,
1121 Err(SdkError::SessionNotFound(id)) if id == "does-not-exist"
1122 ));
1123
1124 let deleted = agent.delete_session("sess-a").await.expect("delete");
1125 assert!(deleted);
1126 assert!(agent
1127 .load_session("sess-a")
1128 .await
1129 .expect("load_session")
1130 .is_none());
1131 }
1132
1133 #[tokio::test]
1134 async fn new_session_uses_effective_config_model_when_builder_model_is_unset() {
1135 let tmp = tempfile::tempdir().expect("tempdir");
1136 let config_json = r#"{
1137 "provider": "anthropic",
1138 "providers": {
1139 "anthropic": { "api_key": "test-key", "model": "configured-model" }
1140 }
1141 }"#;
1142 std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
1143 let agent = AgentBuilder::new()
1144 .with_defaults_for_data_dir(tmp.path().to_path_buf())
1145 .await
1146 .expect("defaults")
1147 .build()
1148 .expect("build");
1149
1150 assert!(
1151 agent.model.is_none(),
1152 "the inferred session model must not become an execution override"
1153 );
1154 let session = agent.new_session("from-config").expect("configured model");
1155 assert_eq!(session.model, "configured-model");
1156 }
1157
1158 #[derive(Default)]
1164 struct RecordingPermissionChecker {
1165 grants: StdMutex<Vec<(String, String, PermissionType, String)>>,
1166 }
1167
1168 #[async_trait]
1169 impl PermissionChecker for RecordingPermissionChecker {
1170 async fn needs_confirmation(&self, _perm_type: PermissionType, _resource: &str) -> bool {
1171 false
1172 }
1173
1174 async fn request_confirmation(
1175 &self,
1176 _ctx: PermissionContext,
1177 ) -> Result<bool, PermissionError> {
1178 Ok(true)
1179 }
1180
1181 fn grant_session_permission(&self, perm_type: PermissionType, resource: String) {
1182 panic!("legacy unscoped grant used: {perm_type:?} {resource}");
1183 }
1184
1185 fn grant_once(
1186 &self,
1187 session_id: &str,
1188 request_id: &str,
1189 perm_type: PermissionType,
1190 resource: String,
1191 ) {
1192 self.grants.lock().unwrap().push((
1193 session_id.to_string(),
1194 request_id.to_string(),
1195 perm_type,
1196 resource,
1197 ));
1198 }
1199
1200 fn set_permission_mode(&self, _mode: PermissionMode) {}
1201 }
1202
1203 #[tokio::test]
1204 async fn answer_applies_permission_grants_to_configured_checker() {
1205 let tmp = tempfile::tempdir().expect("tempdir");
1206 let config_json = r#"{
1207 "provider": "anthropic",
1208 "providers": {
1209 "anthropic": { "api_key": "test-key", "model": "claude-test" }
1210 }
1211 }"#;
1212 std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
1213
1214 let checker = Arc::new(RecordingPermissionChecker::default());
1215 let agent = AgentBuilder::new()
1216 .model("claude-test")
1217 .permission_checker(checker.clone())
1218 .with_defaults_for_data_dir(tmp.path().to_path_buf())
1219 .await
1220 .expect("defaults should assemble")
1221 .build()
1222 .expect("agent should build");
1223
1224 let mut session = Session::new("sess-permission".to_string(), "claude-test".to_string());
1229 session.set_pending_question(
1230 "call-perm-1".to_string(),
1231 "Write".to_string(),
1232 "Permission required".to_string(),
1233 vec!["Approve".to_string(), "Deny".to_string()],
1234 false,
1235 );
1236 session.add_message(Message::tool_result(
1237 "call-perm-1",
1238 serde_json::json!({
1239 "status": "awaiting_permission_approval",
1240 "question": "Permission required",
1241 "permission_type": "write_file",
1242 "resource": "/tmp/example.txt",
1243 "options": ["Approve", "Deny"],
1244 "allow_custom": false,
1245 })
1246 .to_string(),
1247 ));
1248 agent
1249 .storage()
1250 .save_session(&session)
1251 .await
1252 .expect("seed session");
1253
1254 let outcome = agent
1255 .answer("sess-permission", "Approve")
1256 .await
1257 .expect("answer should succeed");
1258 assert_eq!(
1259 outcome.permission_grants,
1260 vec![(PermissionType::WriteFile, "/tmp/example.txt".to_string())]
1261 );
1262
1263 let recorded = checker.grants.lock().unwrap();
1264 assert_eq!(
1265 *recorded,
1266 vec![(
1267 "sess-permission".to_string(),
1268 "call-perm-1".to_string(),
1269 PermissionType::WriteFile,
1270 "/tmp/example.txt".to_string()
1271 )]
1272 );
1273 }
1274
1275 #[tokio::test]
1276 async fn list_sessions_unsupported_without_defaults_for_data_dir() {
1277 let tmp = tempfile::tempdir().expect("tempdir");
1282 let agent = build_test_agent(tmp.path().to_path_buf()).await;
1283 let bare = Agent::from_runtime_with_config(
1284 agent.inner.clone(),
1287 None,
1288 None,
1289 None,
1290 None,
1291 None,
1292 None,
1293 PermissionMode::Default,
1294 );
1295 let result = bare.list_sessions().await;
1296 assert!(matches!(result, Err(SdkError::Unsupported(_))));
1297 assert!(matches!(
1298 bare.new_session("missing-model"),
1299 Err(SdkError::ModelNotConfigured)
1300 ));
1301 }
1302}
1303
1304#[cfg(test)]
1305mod reexecute_and_child_approval_tests {
1306 use super::*;
1307 use bamboo_agent_core::tools::{
1308 FunctionCall, Tool, ToolCall, ToolCtx, ToolError, ToolExecutionSessionFlags, ToolOutcome,
1309 };
1310 use std::sync::atomic::{AtomicUsize, Ordering};
1311 use std::sync::Mutex as StdMutex;
1312 use tokio::sync::Notify;
1313
1314 struct RealOutputTool {
1319 calls: AtomicUsize,
1320 flags: StdMutex<Vec<ToolExecutionSessionFlags>>,
1321 }
1322
1323 impl RealOutputTool {
1324 fn new() -> Self {
1325 Self {
1326 calls: AtomicUsize::new(0),
1327 flags: StdMutex::new(Vec::new()),
1328 }
1329 }
1330 }
1331
1332 struct BlockingRealOutputTool {
1333 calls: AtomicUsize,
1334 entered: Arc<Notify>,
1335 release: Arc<Notify>,
1336 }
1337
1338 #[async_trait]
1339 impl Tool for BlockingRealOutputTool {
1340 fn name(&self) -> &str {
1341 "real_output_tool"
1342 }
1343
1344 fn description(&self) -> &str {
1345 "test-only approved tool that blocks while ownership is challenged"
1346 }
1347
1348 fn parameters_schema(&self) -> serde_json::Value {
1349 serde_json::json!({ "type": "object", "properties": {} })
1350 }
1351
1352 async fn invoke(
1353 &self,
1354 _args: serde_json::Value,
1355 _ctx: ToolCtx,
1356 ) -> Result<ToolOutcome, ToolError> {
1357 self.calls.fetch_add(1, Ordering::SeqCst);
1358 self.entered.notify_one();
1359 self.release.notified().await;
1360 Ok(ToolOutcome::Completed(
1361 bamboo_agent_core::tools::ToolResult::text(true, "BLOCKING REAL OUTPUT"),
1362 ))
1363 }
1364 }
1365
1366 struct ImmediateDoneProvider;
1367
1368 #[async_trait]
1369 impl bamboo_llm::LLMProvider for ImmediateDoneProvider {
1370 async fn chat_stream(
1371 &self,
1372 _messages: &[Message],
1373 _tools: &[bamboo_agent_core::tools::ToolSchema],
1374 _max_output_tokens: Option<u32>,
1375 _model: &str,
1376 ) -> Result<bamboo_llm::LLMStream, bamboo_llm::LLMError> {
1377 Ok(Box::pin(futures::stream::iter([
1378 Ok(bamboo_llm::LLMChunk::Token("done".to_string())),
1379 Ok(bamboo_llm::LLMChunk::Done),
1380 ])))
1381 }
1382 }
1383
1384 #[async_trait]
1385 impl Tool for RealOutputTool {
1386 fn name(&self) -> &str {
1387 "real_output_tool"
1388 }
1389
1390 fn description(&self) -> &str {
1391 "test-only tool that returns a distinctive real result"
1392 }
1393
1394 fn parameters_schema(&self) -> serde_json::Value {
1395 serde_json::json!({ "type": "object", "properties": {} })
1396 }
1397
1398 async fn invoke(
1399 &self,
1400 _args: serde_json::Value,
1401 ctx: ToolCtx,
1402 ) -> Result<ToolOutcome, ToolError> {
1403 let n = self.calls.fetch_add(1, Ordering::SeqCst);
1404 self.flags.lock().unwrap().push(ToolExecutionSessionFlags {
1405 bypass_permissions: ctx.bypass_permissions,
1406 auto_approve_permissions: ctx.auto_approve_permissions,
1407 plan_read_only: ctx.plan_read_only,
1408 });
1409 Ok(ToolOutcome::Completed(
1410 bamboo_agent_core::tools::ToolResult::text(true, format!("REAL TOOL OUTPUT #{n}")),
1411 ))
1412 }
1413 }
1414
1415 async fn build_test_agent_with_tool(
1416 data_dir: std::path::PathBuf,
1417 tool: Arc<RealOutputTool>,
1418 ) -> Agent {
1419 build_test_agent_with_tool_and_mode(data_dir, tool, None).await
1420 }
1421
1422 async fn build_test_agent_with_tool_and_mode(
1423 data_dir: std::path::PathBuf,
1424 tool: Arc<RealOutputTool>,
1425 mode: Option<PermissionMode>,
1426 ) -> Agent {
1427 let config_json = r#"{
1428 "provider": "anthropic",
1429 "providers": {
1430 "anthropic": { "api_key": "test-key", "model": "claude-test" }
1431 }
1432 }"#;
1433 std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
1434
1435 let builder = AgentBuilder::new()
1436 .model("claude-test")
1437 .instruction("test agent")
1438 .tool_shared(tool);
1439 let builder = match mode {
1440 Some(PermissionMode::BypassPermissions) => builder.bypass_permissions(),
1441 Some(mode) => builder.permission_mode(mode),
1442 None => builder,
1443 };
1444 builder
1445 .with_defaults_for_data_dir(data_dir)
1446 .await
1447 .expect("defaults should assemble")
1448 .build()
1449 .expect("agent should build")
1450 }
1451
1452 fn seed_gated_tool_session(session_id: &str, tool_call_id: &str) -> Session {
1457 let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
1458 session.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("test-run"));
1459 session.add_message(Message::assistant(
1460 "",
1461 Some(vec![ToolCall {
1462 id: tool_call_id.to_string(),
1463 tool_type: "function".to_string(),
1464 function: FunctionCall {
1465 name: "real_output_tool".to_string(),
1466 arguments: "{}".to_string(),
1467 },
1468 }]),
1469 ));
1470 session.set_pending_question(
1471 tool_call_id.to_string(),
1472 "real_output_tool".to_string(),
1473 "Permission required".to_string(),
1474 vec!["Approve".to_string(), "Deny".to_string()],
1475 false,
1476 );
1477 session.add_message(Message::tool_result(
1478 tool_call_id,
1479 serde_json::json!({
1480 "status": "awaiting_permission_approval",
1481 "question": "Permission required",
1482 "permission_type": "write_file",
1483 "resource": "/tmp/example.txt",
1484 "options": ["Approve", "Deny"],
1485 "allow_custom": false,
1486 })
1487 .to_string(),
1488 ));
1489 session
1490 }
1491
1492 #[tokio::test]
1493 async fn approve_marks_session_for_reexecution() {
1494 let tmp = tempfile::tempdir().expect("tempdir");
1495 let tool = Arc::new(RealOutputTool::new());
1496 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1497
1498 let session = seed_gated_tool_session("sess-mark", "call-mark-1");
1499 agent
1500 .storage()
1501 .save_session(&session)
1502 .await
1503 .expect("seed session");
1504
1505 let outcome = agent
1506 .answer("sess-mark", "Approve")
1507 .await
1508 .expect("answer should succeed");
1509
1510 assert_eq!(
1511 outcome
1512 .session
1513 .metadata
1514 .get(PERMISSION_REEXECUTE_METADATA_KEY)
1515 .map(String::as_str),
1516 Some("call-mark-1"),
1517 "approving a permission prompt must stamp the re-exec marker"
1518 );
1519 }
1520
1521 #[tokio::test]
1522 async fn deny_does_not_mark_session_for_reexecution() {
1523 let tmp = tempfile::tempdir().expect("tempdir");
1524 let tool = Arc::new(RealOutputTool::new());
1525 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1526
1527 let session = seed_gated_tool_session("sess-deny", "call-deny-1");
1528 agent
1529 .storage()
1530 .save_session(&session)
1531 .await
1532 .expect("seed session");
1533
1534 let outcome = agent
1535 .answer("sess-deny", "Deny")
1536 .await
1537 .expect("answer should succeed");
1538
1539 assert!(outcome.permission_grants.is_empty());
1540 assert!(!outcome
1541 .session
1542 .metadata
1543 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
1544 let tool_message = outcome
1547 .session
1548 .messages
1549 .iter()
1550 .find(|m| m.tool_call_id.as_deref() == Some("call-deny-1"))
1551 .expect("tool result message present");
1552 assert_eq!(tool_message.content, "Selected response: Deny");
1553 }
1554
1555 #[tokio::test]
1556 async fn approve_then_reexecute_runs_real_tool_and_overwrites_placeholder() {
1557 let tmp = tempfile::tempdir().expect("tempdir");
1558 let tool = Arc::new(RealOutputTool::new());
1559 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1560
1561 let session = seed_gated_tool_session("sess-reexec", "call-reexec-1");
1562 agent
1563 .storage()
1564 .save_session(&session)
1565 .await
1566 .expect("seed session");
1567
1568 let outcome = agent
1569 .answer("sess-reexec", "Approve")
1570 .await
1571 .expect("answer should succeed");
1572 let mut session = outcome.session;
1573
1574 let placeholder = session
1577 .messages
1578 .iter()
1579 .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1580 .expect("tool result message present");
1581 assert_eq!(placeholder.content, "Selected response: Approve");
1582 assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1583
1584 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1587 agent
1588 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1589 .await
1590 .expect("authoritative posture is available");
1591 drop(event_tx);
1592
1593 assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
1596 let real_result = session
1597 .messages
1598 .iter()
1599 .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1600 .expect("tool result message present");
1601 assert_eq!(real_result.content, "REAL TOOL OUTPUT #0");
1602 assert_eq!(real_result.tool_success, Some(true));
1603 assert!(
1604 !session
1605 .metadata
1606 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
1607 "the marker must be consumed (removed) after re-execution"
1608 );
1609
1610 let mut saw_tool_complete = false;
1613 while let Ok(event) = event_rx.try_recv() {
1614 if let AgentEvent::ToolComplete { tool_call_id, .. } = event {
1615 assert_eq!(tool_call_id, "call-reexec-1");
1616 saw_tool_complete = true;
1617 }
1618 }
1619 assert!(saw_tool_complete, "expected a ToolComplete event");
1620
1621 let reloaded = agent
1623 .storage()
1624 .load_session("sess-reexec")
1625 .await
1626 .expect("load")
1627 .expect("present");
1628 let reloaded_result = reloaded
1629 .messages
1630 .iter()
1631 .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1632 .expect("tool result message present");
1633 assert_eq!(reloaded_result.content, "REAL TOOL OUTPUT #0");
1634 }
1635
1636 #[tokio::test]
1637 async fn latest_plan_consumes_stale_marker_without_tool_start_or_invocation() {
1638 let tmp = tempfile::tempdir().expect("tempdir");
1639 let tool = Arc::new(RealOutputTool::new());
1640 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1641 let mut session = seed_gated_tool_session("sdk-plan-replay", "plan-call");
1642 session.metadata.insert(
1643 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1644 "plan-call".to_string(),
1645 );
1646
1647 let mut latest = session.clone();
1648 let plan_state: bamboo_domain::PlanModeState = serde_json::from_value(serde_json::json!({
1649 "entered_at": "2026-07-31T00:00:00Z",
1650 "pre_permission_mode": "default",
1651 "status": "exploring"
1652 }))
1653 .expect("valid plan state");
1654 latest.agent_runtime_state.as_mut().unwrap().plan_mode = Some(plan_state);
1655 agent
1656 .storage()
1657 .save_session(&latest)
1658 .await
1659 .expect("persist latest Plan posture");
1660
1661 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1662 agent
1663 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1664 .await
1665 .expect("latest Plan is a handled replay denial");
1666 drop(event_tx);
1667
1668 assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1669 assert!(
1670 event_rx.try_recv().is_err(),
1671 "Plan denial emits no ToolStart"
1672 );
1673 assert!(!session
1674 .metadata
1675 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
1676 let blocked = session
1677 .messages
1678 .iter()
1679 .find(|message| message.tool_call_id.as_deref() == Some("plan-call"))
1680 .expect("blocked result remains in history");
1681 assert_eq!(blocked.tool_success, Some(false));
1682 assert!(blocked.content.contains("Plan mode blocked"));
1683 assert!(session
1684 .agent_runtime_state
1685 .as_ref()
1686 .is_some_and(|runtime| runtime.plan_mode.is_some()));
1687 }
1688
1689 #[tokio::test]
1690 async fn missing_authoritative_posture_retains_marker_and_aborts_replay() {
1691 let tmp = tempfile::tempdir().expect("tempdir");
1692 let tool = Arc::new(RealOutputTool::new());
1693 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1694 let mut session = seed_gated_tool_session("sdk-missing-replay", "missing-call");
1695 session.metadata.insert(
1696 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1697 "missing-call".to_string(),
1698 );
1699
1700 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1701 let error = agent
1702 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1703 .await
1704 .expect_err("missing durable posture must abort resume");
1705 drop(event_tx);
1706
1707 assert!(error.to_string().contains("session missing"));
1708 assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1709 assert!(
1710 event_rx.try_recv().is_err(),
1711 "failed refresh emits no events"
1712 );
1713 assert_eq!(
1714 session
1715 .metadata
1716 .get(PERMISSION_REEXECUTE_METADATA_KEY)
1717 .map(String::as_str),
1718 Some("missing-call"),
1719 "storage failure keeps the approval marker retryable"
1720 );
1721 }
1722
1723 #[tokio::test]
1724 async fn configured_auto_and_explicit_bypass_reach_real_replay_context() {
1725 for (mode, expected) in [
1726 (
1727 PermissionMode::Auto,
1728 ToolExecutionSessionFlags {
1729 bypass_permissions: false,
1730 auto_approve_permissions: true,
1731 plan_read_only: false,
1732 },
1733 ),
1734 (
1735 PermissionMode::BypassPermissions,
1736 ToolExecutionSessionFlags {
1737 bypass_permissions: true,
1738 auto_approve_permissions: false,
1739 plan_read_only: false,
1740 },
1741 ),
1742 ] {
1743 let tmp = tempfile::tempdir().expect("tempdir");
1744 let tool = Arc::new(RealOutputTool::new());
1745 let agent = build_test_agent_with_tool_and_mode(
1746 tmp.path().to_path_buf(),
1747 tool.clone(),
1748 Some(mode),
1749 )
1750 .await;
1751 let mut session = seed_gated_tool_session("sdk-flags-replay", "flags-call");
1752 session.metadata.insert(
1753 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1754 "flags-call".to_string(),
1755 );
1756 agent.storage().save_session(&session).await.unwrap();
1757
1758 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
1759 agent
1760 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1761 .await
1762 .expect("configured replay should execute");
1763
1764 assert_eq!(*tool.flags.lock().unwrap(), vec![expected]);
1765 }
1766 }
1767
1768 #[tokio::test]
1769 async fn rejected_clone_never_enters_approved_mutating_tool_replay() {
1770 let tmp = tempfile::tempdir().expect("tempdir");
1771 let config_json = r#"{
1772 "provider": "anthropic",
1773 "providers": {
1774 "anthropic": { "api_key": "test-key", "model": "claude-test" }
1775 }
1776 }"#;
1777 std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
1778
1779 let entered = Arc::new(Notify::new());
1780 let release = Arc::new(Notify::new());
1781 let tool = Arc::new(BlockingRealOutputTool {
1782 calls: AtomicUsize::new(0),
1783 entered: entered.clone(),
1784 release: release.clone(),
1785 });
1786 let router = bamboo_engine::SessionActivationRouter::new();
1787 let agent = AgentBuilder::new()
1788 .model("claude-test")
1789 .instruction("test agent")
1790 .provider(Arc::new(ImmediateDoneProvider))
1791 .tool_shared(tool.clone())
1792 .session_delivery(router)
1793 .with_defaults_for_data_dir(tmp.path().to_path_buf())
1794 .await
1795 .expect("defaults should assemble")
1796 .build()
1797 .expect("agent should build");
1798
1799 let mut first_session = seed_gated_tool_session("approved-replay-owner", "approved-call");
1800 first_session.metadata.insert(
1801 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1802 "approved-call".to_string(),
1803 );
1804 first_session.add_message(Message::user("continue after approval"));
1805 agent
1806 .storage()
1807 .save_session(&first_session)
1808 .await
1809 .expect("seed approved session");
1810 let mut rejected_session = first_session.clone();
1811
1812 let first_agent = agent.clone();
1813 let first = tokio::spawn(async move { first_agent.run_session(&mut first_session).await });
1814 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1815 .await
1816 .expect("first owner must enter approved tool replay");
1817
1818 let collision = tokio::time::timeout(
1819 std::time::Duration::from_secs(5),
1820 agent.run_session(&mut rejected_session),
1821 )
1822 .await
1823 .expect("rejected clone must fail promptly")
1824 .expect_err("a second logical-session owner must collide");
1825 assert!(
1826 collision
1827 .to_string()
1828 .contains("session activation owner collision"),
1829 "unexpected collision error: {collision}"
1830 );
1831 assert_eq!(
1832 tool.calls.load(Ordering::SeqCst),
1833 1,
1834 "the rejected clone must collide before entering a mutating tool"
1835 );
1836
1837 release.notify_one();
1838 tokio::time::timeout(std::time::Duration::from_secs(5), first)
1839 .await
1840 .expect("first owner must finish")
1841 .expect("first owner task must not panic")
1842 .expect("first owner execution must succeed");
1843 assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
1844 }
1845
1846 #[tokio::test]
1847 async fn reexecute_is_noop_without_pending_marker() {
1848 let tmp = tempfile::tempdir().expect("tempdir");
1849 let tool = Arc::new(RealOutputTool::new());
1850 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1851
1852 let mut session = Session::new("sess-noop".to_string(), "claude-test".to_string());
1853 session.add_message(Message::user("hi"));
1854
1855 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
1856 agent
1857 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1858 .await
1859 .expect("missing marker is a no-op");
1860
1861 assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1862 assert_eq!(session.messages.len(), 1);
1863 }
1864
1865 #[tokio::test]
1866 async fn reexecute_warns_and_clears_marker_when_tool_call_missing() {
1867 let tmp = tempfile::tempdir().expect("tempdir");
1868 let tool = Arc::new(RealOutputTool::new());
1869 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1870
1871 let mut session = Session::new("sess-missing".to_string(), "claude-test".to_string());
1872 session.metadata.insert(
1874 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1875 "ghost-call".to_string(),
1876 );
1877
1878 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1879 agent
1880 .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1881 .await
1882 .expect("missing tool call clears the marker without replay");
1883 drop(event_tx);
1884
1885 assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1886 assert!(event_rx.try_recv().is_err(), "no events should be emitted");
1887 assert!(
1888 !session
1889 .metadata
1890 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
1891 "the marker is removed even when the tool call can't be found, so a \
1892 missing/pruned call can't wedge every future execution"
1893 );
1894 }
1895
1896 #[tokio::test]
1897 async fn answer_child_approval_delivers_only_genuinely_pending_requests() {
1898 let tmp = tempfile::tempdir().expect("tempdir");
1899 let tool = Arc::new(RealOutputTool::new());
1900 let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1901
1902 assert!(!agent.answer_child_approval("child-x", "req-unknown", true));
1904
1905 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1911 let _live_guard = bamboo_engine::external_agents::live::register("child-x", tx, 0, None);
1912 let (approval_event_tx, _approval_event_rx) = tokio::sync::mpsc::channel(4);
1913 bamboo_engine::external_agents::live::observe_pending_approval(
1914 bamboo_engine::external_agents::live::PendingApprovalObservation {
1915 registry: None,
1916 parent_session_id: "parent-x",
1917 child_id: "child-x",
1918 child_attempt: 0,
1919 request_id: "req-1",
1920 tool_name: "shell",
1921 permission: "execute",
1922 resource: "cargo test",
1923 event_tx: approval_event_tx,
1924 },
1925 );
1926
1927 assert!(agent.answer_child_approval("child-x", "req-1", true));
1928 match rx.try_recv() {
1929 Ok(bamboo_subagent::proto::ParentFrame::ApprovalReply { id, approved }) => {
1930 assert_eq!(id, "req-1");
1931 assert!(approved);
1932 }
1933 other => panic!("expected an ApprovalReply frame, got {other:?}"),
1934 }
1935
1936 assert!(!agent.answer_child_approval("child-x", "req-1", true));
1938 }
1939}