1use std::sync::atomic::{AtomicUsize, Ordering};
4use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
5
6use bamboo_agent_core::{Message, PendingQuestion, Session};
7use bamboo_domain::session::runtime_state::{AgentRuntimeState, PlanModeState, PlanModeStatus};
8use bamboo_domain::{
9 latest_response_occurrence, ResponseOccurrence, SessionPermissionMode,
10 CONSUMED_CLARIFICATION_IDS_KEY, CONSUMED_RESPONSE_OCCURRENCES_KEY,
11};
12use bamboo_tools::permission::{PermissionDecisionKind, PermissionDecisionReceipt, PermissionType};
13use chrono::Utc;
14use dashmap::mapref::entry::Entry;
15use dashmap::DashMap;
16use tokio::sync::{Mutex, OwnedMutexGuard};
17
18use super::errors::RespondError;
19use super::execute::mark_startup_handoff;
20use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
21use super::repository::SessionAccess;
22use super::types::RespondInput;
23
24const CLARIFICATION_RESUME_PENDING_KEY: &str = "clarification_resume_pending";
25const CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY: &str = "conclusion_with_options_resume_pending";
26
27type AppliedPendingResponse = (
28 String,
29 Option<PlanModeTransition>,
30 Vec<(PermissionType, String)>,
31);
32
33struct PendingResponseGate {
40 lock: Arc<Mutex<()>>,
41 waiters: AtomicUsize,
42}
43
44impl PendingResponseGate {
45 fn new() -> Self {
46 Self {
47 lock: Arc::new(Mutex::new(())),
48 waiters: AtomicUsize::new(0),
49 }
50 }
51}
52
53struct PendingResponseWaiter(Arc<PendingResponseGate>);
54
55impl Drop for PendingResponseWaiter {
56 fn drop(&mut self) {
57 self.0.waiters.fetch_sub(1, Ordering::SeqCst);
58 }
59}
60
61fn pending_response_locks() -> &'static DashMap<String, Weak<PendingResponseGate>> {
62 static LOCKS: OnceLock<DashMap<String, Weak<PendingResponseGate>>> = OnceLock::new();
63 LOCKS.get_or_init(DashMap::new)
64}
65
66fn pending_response_lock(session_id: &str) -> Arc<PendingResponseGate> {
67 let locks = pending_response_locks();
68 locks.retain(|_, lock| lock.strong_count() > 0);
69 match locks.entry(session_id.to_string()) {
70 Entry::Occupied(mut entry) => {
71 if let Some(lock) = entry.get().upgrade() {
72 lock
73 } else {
74 let lock = Arc::new(PendingResponseGate::new());
75 entry.insert(Arc::downgrade(&lock));
76 lock
77 }
78 }
79 Entry::Vacant(entry) => {
80 let lock = Arc::new(PendingResponseGate::new());
81 entry.insert(Arc::downgrade(&lock));
82 lock
83 }
84 }
85}
86
87pub struct PendingResponseGuard {
92 session_id: String,
93 _gate: Arc<PendingResponseGate>,
94 _guard: OwnedMutexGuard<()>,
95}
96
97impl PendingResponseGuard {
98 fn ensure_session(&self, session_id: &str) -> Result<(), RespondError> {
99 if self.session_id == session_id {
100 Ok(())
101 } else {
102 Err(RespondError::InvalidResponse(
103 "response transaction guard belongs to a different session".to_string(),
104 ))
105 }
106 }
107}
108
109pub async fn acquire_pending_response_guard(session_id: &str) -> PendingResponseGuard {
111 let gate = pending_response_lock(session_id);
112 gate.waiters.fetch_add(1, Ordering::SeqCst);
113 let waiter = PendingResponseWaiter(gate.clone());
114 let guard = gate.lock.clone().lock_owned().await;
115 drop(waiter);
116 PendingResponseGuard {
117 session_id: session_id.to_string(),
118 _gate: gate,
119 _guard: guard,
120 }
121}
122
123#[doc(hidden)]
126pub fn pending_response_waiter_count(session_id: &str) -> usize {
127 pending_response_locks()
128 .get(session_id)
129 .and_then(|entry| entry.upgrade())
130 .map(|gate| gate.waiters.load(Ordering::SeqCst))
131 .unwrap_or(0)
132}
133
134pub async fn inspect_pending_response_guarded(
138 repo: &dyn SessionAccess,
139 session_id: &str,
140 guard: &PendingResponseGuard,
141) -> Result<Option<Session>, RespondError> {
142 guard.ensure_session(session_id)?;
143 Ok(repo.inspect_for_response(session_id).await?)
144}
145
146pub const PERMISSION_REEXECUTE_METADATA_KEY: &str = "permission.reexecute_tool_call_id";
152pub const PERMISSION_REEXECUTE_GENERATION_METADATA_KEY: &str =
153 "permission.reexecute_request_generation";
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum ResponseSource {
157 Human,
158 Gold,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum PlanModeTransition {
163 Entered {
164 reason: Option<String>,
165 pre_permission_mode: String,
166 entered_at: chrono::DateTime<chrono::Utc>,
167 status: PlanModeStatus,
168 plan_file_path: Option<String>,
169 },
170 Exited {
171 approved: bool,
172 restored_mode: String,
173 plan: Option<String>,
174 },
175}
176
177pub async fn submit_pending_response(
182 repo: &dyn SessionAccess,
183 input: RespondInput,
184) -> Result<
185 (
186 Session,
187 String,
188 Option<PlanModeTransition>,
189 Vec<(PermissionType, String)>,
190 ),
191 RespondError,
192> {
193 submit_pending_response_checked(repo, input, None).await
194}
195
196pub async fn submit_pending_response_checked(
200 repo: &dyn SessionAccess,
201 input: RespondInput,
202 expected_tool_call_id: Option<String>,
203) -> Result<
204 (
205 Session,
206 String,
207 Option<PlanModeTransition>,
208 Vec<(PermissionType, String)>,
209 ),
210 RespondError,
211> {
212 submit_pending_response_with_source_checked(
213 repo,
214 input,
215 expected_tool_call_id,
216 ResponseSource::Human,
217 )
218 .await
219}
220
221pub async fn submit_pending_response_checked_guarded(
224 repo: &dyn SessionAccess,
225 input: RespondInput,
226 expected_tool_call_id: Option<String>,
227 guard: &PendingResponseGuard,
228) -> Result<
229 (
230 Session,
231 String,
232 Option<PlanModeTransition>,
233 Vec<(PermissionType, String)>,
234 ),
235 RespondError,
236> {
237 submit_pending_response_with_source_checked_guarded(
238 repo,
239 input,
240 expected_tool_call_id,
241 ResponseSource::Human,
242 guard,
243 )
244 .await
245}
246
247pub async fn submit_pending_permission_response_checked_guarded(
250 repo: &dyn SessionAccess,
251 input: RespondInput,
252 expected_tool_call_id: Option<String>,
253 permission_receipt: PermissionDecisionReceipt,
254 guard: &PendingResponseGuard,
255) -> Result<
256 (
257 Session,
258 String,
259 Option<PlanModeTransition>,
260 Vec<(PermissionType, String)>,
261 ),
262 RespondError,
263> {
264 submit_pending_response_with_source_checked_guarded_inner(
265 repo,
266 input,
267 expected_tool_call_id,
268 ResponseSource::Human,
269 Some(permission_receipt),
270 guard,
271 )
272 .await
273}
274
275pub async fn submit_pending_response_with_source(
276 repo: &dyn SessionAccess,
277 input: RespondInput,
278 response_source: ResponseSource,
279) -> Result<
280 (
281 Session,
282 String,
283 Option<PlanModeTransition>,
284 Vec<(PermissionType, String)>,
285 ),
286 RespondError,
287> {
288 submit_pending_response_with_source_checked(repo, input, None, response_source).await
289}
290
291pub async fn submit_pending_response_with_source_checked(
292 repo: &dyn SessionAccess,
293 input: RespondInput,
294 expected_tool_call_id: Option<String>,
295 response_source: ResponseSource,
296) -> Result<
297 (
298 Session,
299 String,
300 Option<PlanModeTransition>,
301 Vec<(PermissionType, String)>,
302 ),
303 RespondError,
304> {
305 let guard = acquire_pending_response_guard(&input.session_id).await;
306 submit_pending_response_with_source_checked_guarded(
307 repo,
308 input,
309 expected_tool_call_id,
310 response_source,
311 &guard,
312 )
313 .await
314}
315
316pub async fn submit_pending_response_with_source_checked_guarded(
320 repo: &dyn SessionAccess,
321 input: RespondInput,
322 expected_tool_call_id: Option<String>,
323 response_source: ResponseSource,
324 guard: &PendingResponseGuard,
325) -> Result<
326 (
327 Session,
328 String,
329 Option<PlanModeTransition>,
330 Vec<(PermissionType, String)>,
331 ),
332 RespondError,
333> {
334 submit_pending_response_with_source_checked_guarded_inner(
335 repo,
336 input,
337 expected_tool_call_id,
338 response_source,
339 None,
340 guard,
341 )
342 .await
343}
344
345async fn submit_pending_response_with_source_checked_guarded_inner(
346 repo: &dyn SessionAccess,
347 input: RespondInput,
348 expected_tool_call_id: Option<String>,
349 response_source: ResponseSource,
350 permission_receipt: Option<PermissionDecisionReceipt>,
351 guard: &PendingResponseGuard,
352) -> Result<
353 (
354 Session,
355 String,
356 Option<PlanModeTransition>,
357 Vec<(PermissionType, String)>,
358 ),
359 RespondError,
360> {
361 guard.ensure_session(&input.session_id)?;
362
363 let applied = Arc::new(StdMutex::new(None));
364 let mutation_outcome = applied.clone();
365 let mutation_input = input.clone();
366 let mutation_expected_tool_call_id = expected_tool_call_id.clone();
367 let mutation_permission_receipt = permission_receipt.clone();
368 let session = repo
369 .mutate_for_response(
370 &input.session_id,
371 Box::new(move |session| {
372 let outcome = apply_pending_response(
373 session,
374 &mutation_input,
375 mutation_expected_tool_call_id.as_deref(),
376 response_source,
377 mutation_permission_receipt.as_ref(),
378 )?;
379 *mutation_outcome
380 .lock()
381 .expect("response outcome lock poisoned") = Some(outcome);
382 Ok(())
383 }),
384 )
385 .await?
386 .ok_or_else(|| RespondError::NotFound(input.session_id.clone()))?;
387 let (user_response, plan_mode_transition, permission_grants) = applied
388 .lock()
389 .expect("response outcome lock poisoned")
390 .take()
391 .expect("successful response mutation records its outcome");
392
393 tracing::info!(
394 "[{}] Response processed successfully, agent loop can resume",
395 input.session_id
396 );
397
398 Ok((
399 session,
400 user_response,
401 plan_mode_transition,
402 permission_grants,
403 ))
404}
405
406fn apply_pending_response(
407 session: &mut Session,
408 input: &RespondInput,
409 expected_tool_call_id: Option<&str>,
410 response_source: ResponseSource,
411 permission_receipt: Option<&PermissionDecisionReceipt>,
412) -> Result<AppliedPendingResponse, RespondError> {
413 session
414 .check_execution_compatibility()
415 .map_err(|error| RespondError::InvalidResponse(error.to_string()))?;
416 let pending = session
417 .pending_question
418 .take()
419 .ok_or(RespondError::NoPendingQuestion)?;
420
421 if pending.source == bamboo_agent_core::PendingQuestionSource::DirectParent {
422 session.pending_question = Some(pending);
423 return Err(RespondError::InvalidResponse(
424 "Direct-parent clarification requires a canonical ParentRequest reply".into(),
425 ));
426 }
427
428 if session
429 .messages
430 .iter()
431 .rev()
432 .find(|message| message.tool_call_id.as_deref() == Some(pending.tool_call_id.as_str()))
433 .is_some_and(result_payload_has_supervisor_authority)
434 {
435 session.pending_question = Some(pending);
436 return Err(RespondError::InvalidResponse(
437 "Supervisor authority cannot originate in a tool result payload".into(),
438 ));
439 }
440
441 if let Some(expected) = expected_tool_call_id {
442 if pending.tool_call_id != expected {
443 let actual = pending.tool_call_id.clone();
444 session.pending_question = Some(pending);
445 return Err(RespondError::PendingQuestionMismatch {
446 expected: expected.to_string(),
447 actual,
448 });
449 }
450 }
451
452 if let Some(receipt) = permission_receipt {
453 if receipt.session_id != input.session_id
454 || receipt.decision.request_id != pending.tool_call_id
455 {
456 session.pending_question = Some(pending);
457 return Err(RespondError::InvalidResponse(
458 "permission receipt identity does not match the pending question".to_string(),
459 ));
460 }
461 let current_generation = session
462 .messages
463 .iter()
464 .rev()
465 .find(|message| message.tool_call_id.as_deref() == Some(pending.tool_call_id.as_str()))
466 .and_then(permission_request_generation);
467 if current_generation.as_deref() != Some(receipt.decision.request_generation.as_str()) {
468 session.pending_question = Some(pending);
469 return Err(RespondError::InvalidResponse(
470 "permission receipt generation does not match the pending operation".to_string(),
471 ));
472 }
473 }
474
475 if permission_receipt.is_none() {
479 if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
480 session.pending_question = Some(pending);
482 return Err(RespondError::InvalidResponse(error_message));
483 }
484 }
485
486 let no_progress_question = super::no_progress::is_no_progress_question(session, &pending);
487 if no_progress_question
488 && (response_source != ResponseSource::Human || permission_receipt.is_some())
489 {
490 session.pending_question = Some(pending);
491 return Err(RespondError::InvalidResponse(
492 "A progress pause requires a Human response".into(),
493 ));
494 }
495
496 let tool_call_id = pending.tool_call_id.clone();
497 tracing::debug!(
498 "[{}] Looking for tool result message with tool_call_id: {}",
499 input.session_id,
500 tool_call_id
501 );
502
503 let reviewed_plan = extract_exit_plan_from_tool_result_message(session, &tool_call_id);
504
505 let typed_permission_approved = permission_receipt.is_some_and(|receipt| {
509 matches!(
510 receipt.decision.decision,
511 PermissionDecisionKind::AllowOnce
512 | PermissionDecisionKind::AllowSession
513 | PermissionDecisionKind::AllowWorkspace
514 | PermissionDecisionKind::AllowGlobal
515 )
516 });
517 let permission_approved = permission_receipt
518 .map(|_| typed_permission_approved)
519 .unwrap_or_else(|| is_permission_approval(&input.user_response));
520 let permission_grants = if permission_approved {
521 extract_permission_grants_from_tool_result_message(session, &tool_call_id)
522 } else {
523 Vec::new()
524 };
525 let should_reexecute = permission_receipt
526 .map(|_| typed_permission_approved)
527 .unwrap_or(!permission_grants.is_empty());
528 if should_reexecute {
529 session.metadata.insert(
533 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
534 tool_call_id.clone(),
535 );
536 if let Some(receipt) = permission_receipt {
537 session.metadata.insert(
538 PERMISSION_REEXECUTE_GENERATION_METADATA_KEY.to_string(),
539 receipt.decision.request_generation.clone(),
540 );
541 } else {
542 session
543 .metadata
544 .remove(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY);
545 }
546 } else if permission_receipt.is_some() {
547 session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
549 session
550 .metadata
551 .remove(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY);
552 }
553
554 let found = if no_progress_question {
556 let mut direction = Message::user(input.user_response.clone());
559 if input.user_response == super::no_progress::CONTINUE_OPTION {
560 direction.metadata = Some(serde_json::json!({"runtime_kind": "no_progress_continue"}));
561 }
562 session.add_message(direction);
563 true
564 } else {
565 update_or_append_tool_result_message(
566 session,
567 &tool_call_id,
568 &input.user_response,
569 response_source,
570 )
571 };
572 if let Some(receipt) = permission_receipt {
573 if !persist_permission_decision_receipt(session, &tool_call_id, receipt) {
574 return Err(RespondError::InvalidResponse(
575 "permission receipt could not be persisted for the pending operation".to_string(),
576 ));
577 }
578 }
579 if found {
580 tracing::info!(
581 "[{}] Updated existing tool result message",
582 input.session_id
583 );
584 } else {
585 tracing::warn!(
586 "[{}] Tool result message not found for tool_call_id: {}, added fallback message",
587 input.session_id,
588 tool_call_id
589 );
590 }
591
592 let plan_mode_transition =
594 apply_plan_mode_transition(session, &pending, &input.user_response, reviewed_plan);
595
596 session.clear_pending_question();
598 record_consumed_clarification(session, &tool_call_id);
599 session.metadata.remove("runtime.suspend_reason");
600 if no_progress_question && input.user_response == super::no_progress::STOP_OPTION {
601 super::no_progress::stop_after_response(session);
602 return Ok((input.user_response.clone(), None, Vec::new()));
603 }
604 session.metadata.insert(
605 CLARIFICATION_RESUME_PENDING_KEY.to_string(),
606 "true".to_string(),
607 );
608 session.metadata.insert(
609 CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY.to_string(),
610 "true".to_string(),
611 );
612 mark_startup_handoff(session);
613
614 let request_model_ref = derive_model_ref(
616 input.model_ref.as_ref(),
617 input.provider.as_deref(),
618 input.model.as_deref(),
619 );
620 if let Some(model_ref) = request_model_ref.as_ref() {
621 persist_model_ref(session, model_ref);
622 } else {
623 persist_legacy_model_provider(session, input.model.as_deref(), input.provider.as_deref());
624 }
625 if let Some(reasoning_effort) = input.reasoning_effort {
626 session.reasoning_effort = Some(reasoning_effort);
627 }
628
629 Ok((
630 input.user_response.clone(),
631 plan_mode_transition,
632 permission_grants,
633 ))
634}
635
636fn record_consumed_clarification(session: &mut Session, tool_call_id: &str) {
637 let mut legacy_consumed = session
638 .metadata
639 .get(CONSUMED_CLARIFICATION_IDS_KEY)
640 .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
641 .unwrap_or_default();
642 legacy_consumed.retain(|existing| existing != tool_call_id);
643 legacy_consumed.push(tool_call_id.to_string());
644 if legacy_consumed.len() > 64 {
645 legacy_consumed.drain(..legacy_consumed.len() - 64);
646 }
647 if let Ok(serialized) = serde_json::to_string(&legacy_consumed) {
648 session
649 .metadata
650 .insert(CONSUMED_CLARIFICATION_IDS_KEY.to_string(), serialized);
651 }
652
653 let Some(occurrence) = latest_response_occurrence(session, tool_call_id) else {
654 return;
655 };
656 let mut consumed = session
657 .metadata
658 .get(CONSUMED_RESPONSE_OCCURRENCES_KEY)
659 .and_then(|value| serde_json::from_str::<Vec<ResponseOccurrence>>(value).ok())
660 .unwrap_or_default();
661 consumed.retain(|existing| existing != &occurrence);
662 consumed.push(occurrence);
663 if consumed.len() > 64 {
664 consumed.drain(..consumed.len() - 64);
665 }
666 if let Ok(serialized) = serde_json::to_string(&consumed) {
667 session
668 .metadata
669 .insert(CONSUMED_RESPONSE_OCCURRENCES_KEY.to_string(), serialized);
670 }
671}
672
673fn apply_plan_mode_transition(
675 session: &mut Session,
676 pending: &PendingQuestion,
677 user_response: &str,
678 reviewed_plan: Option<String>,
679) -> Option<PlanModeTransition> {
680 match pending.tool_name.as_str() {
681 "EnterPlanMode" if user_response.to_lowercase().contains("enter plan mode") => {
682 let pre_mode = session
683 .agent_runtime_state
684 .as_ref()
685 .map(|state| state.effective_permission_mode().as_str().to_string())
686 .unwrap_or_else(|| SessionPermissionMode::Default.as_str().to_string());
687
688 let entered_at = Utc::now();
689 let status = PlanModeStatus::Exploring;
690 let runtime_state = session
691 .agent_runtime_state
692 .get_or_insert_with(|| AgentRuntimeState::new(uuid::Uuid::new_v4().to_string()));
693 runtime_state.plan_mode = Some(PlanModeState {
694 entered_at,
695 pre_permission_mode: pre_mode.clone(),
696 plan_file_path: None,
697 status,
698 });
699 tracing::info!(
700 session_id = %session.id,
701 "Entered plan mode"
702 );
703 Some(PlanModeTransition::Entered {
704 reason: Some(pending.question.clone()),
705 pre_permission_mode: pre_mode,
706 entered_at,
707 status,
708 plan_file_path: None,
709 })
710 }
711 "ExitPlanMode" if is_exit_plan_mode_approved(user_response) => {
712 let restored_mode = session
713 .agent_runtime_state
714 .as_ref()
715 .map(|state| state.effective_permission_mode().as_str().to_string())
716 .unwrap_or_else(|| "default".to_string());
717 if let Some(ref mut runtime_state) = session.agent_runtime_state {
718 runtime_state.plan_mode = None;
723 }
724 tracing::info!(
725 session_id = %session.id,
726 "Exited plan mode"
727 );
728 Some(PlanModeTransition::Exited {
729 approved: true,
730 restored_mode,
731 plan: reviewed_plan,
732 })
733 }
734 _ => None,
735 }
736}
737
738fn is_exit_plan_mode_approved(user_response: &str) -> bool {
740 let lower = user_response.to_lowercase();
741 lower.contains("approve") && !lower.contains("stay in plan mode")
742}
743
744pub fn validate_pending_response(
747 pending: &PendingQuestion,
748 user_response: &str,
749) -> Result<(), String> {
750 if pending.allow_custom {
751 return Ok(());
752 }
753
754 let valid = pending.options.iter().any(|option| option == user_response);
755 if valid {
756 Ok(())
757 } else {
758 let options_str = pending.options.join(", ");
759 Err(format!("Response must be one of: {options_str}"))
760 }
761}
762
763pub fn update_or_append_tool_result_message(
764 session: &mut Session,
765 tool_call_id: &str,
766 user_response: &str,
767 response_source: ResponseSource,
768) -> bool {
769 for message in session.messages.iter_mut().rev() {
770 if message.tool_call_id.as_deref() == Some(tool_call_id) {
771 if result_payload_has_supervisor_authority(message) {
774 return false;
775 }
776 if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&message.content) {
782 if payload.get("status").and_then(serde_json::Value::as_str)
783 == Some("awaiting_permission_approval")
784 {
785 if let Some(request) = payload.get("permission_request") {
786 insert_message_metadata(message, "permission_request", request.clone());
787 }
788 }
789 }
790 message.content = selected_message_content(user_response, response_source);
791 message.tool_success = Some(true);
792 return true;
793 }
794 }
795
796 session.add_message(bamboo_agent_core::Message::tool_result_with_status(
797 tool_call_id,
798 selected_message_content(user_response, response_source),
799 true,
800 ));
801 false
802}
803
804fn result_payload_has_supervisor_authority(message: &Message) -> bool {
805 serde_json::from_str::<serde_json::Value>(&message.content).ok().is_some_and(|payload| {
806 payload.get(bamboo_agent_core::tools::ExecutingSupervisorObservation::PERMISSION_REPLAY_METADATA_KEY).is_some()
807 || payload.get(bamboo_agent_core::tools::PENDING_TOOL_OPERATION_METADATA_KEY).is_some()
808 })
809}
810
811fn insert_message_metadata(message: &mut Message, key: &str, value: serde_json::Value) {
812 let metadata = message
813 .metadata
814 .get_or_insert_with(|| serde_json::Value::Object(Default::default()));
815 if !metadata.is_object() {
816 let previous = std::mem::replace(metadata, serde_json::Value::Object(Default::default()));
817 metadata
818 .as_object_mut()
819 .expect("replacement metadata is an object")
820 .insert("previous_metadata".to_string(), previous);
821 }
822 metadata
823 .as_object_mut()
824 .expect("message metadata is an object")
825 .insert(key.to_string(), value);
826}
827
828fn permission_request_generation(message: &Message) -> Option<String> {
829 message
830 .metadata
831 .as_ref()
832 .and_then(|metadata| metadata.get("permission_request"))
833 .and_then(|request| request.get("request_generation"))
834 .and_then(serde_json::Value::as_str)
835 .map(ToOwned::to_owned)
836 .or_else(|| {
837 serde_json::from_str::<serde_json::Value>(&message.content)
838 .ok()?
839 .get("permission_request")?
840 .get("request_generation")?
841 .as_str()
842 .map(ToOwned::to_owned)
843 })
844}
845
846fn persist_permission_decision_receipt(
847 session: &mut Session,
848 tool_call_id: &str,
849 receipt: &PermissionDecisionReceipt,
850) -> bool {
851 let Some(message) = session
852 .messages
853 .iter_mut()
854 .rev()
855 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))
856 else {
857 return false;
858 };
859 if permission_request_generation(message).as_deref()
860 != Some(receipt.decision.request_generation.as_str())
861 {
862 return false;
863 }
864 insert_message_metadata(
865 message,
866 "permission_decision_receipt",
867 serde_json::to_value(receipt).expect("permission receipt is serializable"),
868 );
869 true
870}
871
872fn selected_message_content(user_response: &str, response_source: ResponseSource) -> String {
873 match response_source {
874 ResponseSource::Human => format!("Selected response: {}", user_response),
875 ResponseSource::Gold => format!("Auto-selected response (gold): {}", user_response),
876 }
877}
878
879fn extract_exit_plan_from_tool_result_message(
880 session: &Session,
881 tool_call_id: &str,
882) -> Option<String> {
883 let message = session
884 .messages
885 .iter()
886 .rev()
887 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))?;
888 let payload = serde_json::from_str::<serde_json::Value>(&message.content).ok()?;
889 payload
890 .get("plan")
891 .and_then(|value| value.as_str())
892 .map(str::trim)
893 .filter(|value| !value.is_empty())
894 .map(ToOwned::to_owned)
895}
896
897fn is_permission_approval(user_response: &str) -> bool {
902 user_response.trim().eq_ignore_ascii_case("approve")
903}
904
905fn extract_permission_grants_from_tool_result_message(
914 session: &Session,
915 tool_call_id: &str,
916) -> Vec<(PermissionType, String)> {
917 let message = match session
918 .messages
919 .iter()
920 .rev()
921 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))
922 {
923 Some(message) => message,
924 None => return Vec::new(),
925 };
926 let payload = match serde_json::from_str::<serde_json::Value>(&message.content) {
927 Ok(payload) => payload,
928 Err(_) => return Vec::new(),
929 };
930 if payload.get("status").and_then(|value| value.as_str())
931 != Some("awaiting_permission_approval")
932 {
933 return Vec::new();
934 }
935
936 let parse_one = |value: &serde_json::Value| -> Option<(PermissionType, String)> {
937 let type_value = value
938 .get("permission_type")
939 .or_else(|| value.get("type"))?
940 .clone();
941 let perm_type: PermissionType = serde_json::from_value(type_value).ok()?;
942 let resource = value.get("resource")?.as_str()?.trim().to_string();
943 if resource.is_empty() {
944 return None;
945 }
946 Some((perm_type, resource))
947 };
948
949 if let Some(array) = payload
950 .get("permissions")
951 .and_then(|value| value.as_array())
952 {
953 array.iter().filter_map(parse_one).collect()
954 } else {
955 parse_one(&payload).into_iter().collect()
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
963 use std::sync::Mutex;
964 use tokio::sync::Notify;
965
966 struct TestSessionAccess {
967 session: Mutex<Session>,
968 loads: AtomicUsize,
969 saves: AtomicUsize,
970 block_first_save: AtomicBool,
971 save_started: Notify,
972 release_save: Notify,
973 }
974
975 impl TestSessionAccess {
976 fn with_pending() -> Self {
977 Self::with_pending_and_blocked_save(false)
978 }
979
980 fn with_pending_and_blocked_save(block_first_save: bool) -> Self {
981 let mut session = Session::new("sess-1", "test-model");
982 session.pending_question = Some(make_pending("ConclusionWithOptions"));
983 Self {
984 session: Mutex::new(session),
985 loads: AtomicUsize::new(0),
986 saves: AtomicUsize::new(0),
987 block_first_save: AtomicBool::new(block_first_save),
988 save_started: Notify::new(),
989 release_save: Notify::new(),
990 }
991 }
992 }
993
994 #[async_trait::async_trait]
995 impl SessionAccess for TestSessionAccess {
996 async fn load_session(
997 &self,
998 _id: &str,
999 ) -> Result<Option<Session>, super::super::errors::SessionLoadError> {
1000 Ok(Some(self.session.lock().unwrap().clone()))
1001 }
1002
1003 async fn load_or_create(
1004 &self,
1005 _id: &str,
1006 _model: &str,
1007 ) -> Result<Session, super::super::errors::SessionLoadError> {
1008 Ok(self.session.lock().unwrap().clone())
1009 }
1010
1011 async fn load_merged(
1012 &self,
1013 _id: &str,
1014 ) -> Result<Option<Session>, super::super::errors::SessionLoadError> {
1015 self.loads.fetch_add(1, Ordering::SeqCst);
1016 Ok(Some(self.session.lock().unwrap().clone()))
1017 }
1018
1019 async fn save_session(
1020 &self,
1021 session: &mut Session,
1022 ) -> Result<(), super::super::errors::SessionSaveError> {
1023 if self.block_first_save.swap(false, Ordering::SeqCst) {
1024 self.save_started.notify_one();
1025 self.release_save.notified().await;
1026 }
1027 *self.session.lock().unwrap() = session.clone();
1028 self.saves.fetch_add(1, Ordering::SeqCst);
1029 Ok(())
1030 }
1031
1032 async fn save_and_cache(
1033 &self,
1034 session: &mut Session,
1035 ) -> Result<(), super::super::errors::SessionSaveError> {
1036 self.save_session(session).await
1037 }
1038 }
1039
1040 fn respond_input() -> RespondInput {
1041 RespondInput {
1042 session_id: "sess-1".to_string(),
1043 user_response: "A".to_string(),
1044 model: None,
1045 model_ref: None,
1046 provider: None,
1047 reasoning_effort: None,
1048 }
1049 }
1050
1051 fn make_pending(tool_name: &str) -> PendingQuestion {
1052 PendingQuestion {
1053 tool_call_id: "call-1".to_string(),
1054 tool_name: tool_name.to_string(),
1055 question: "Question?".to_string(),
1056 options: vec!["A".to_string(), "B".to_string()],
1057 allow_custom: false,
1058 source: bamboo_agent_core::PendingQuestionSource::PauseTool,
1059 }
1060 }
1061
1062 #[tokio::test]
1063 async fn expected_tool_call_id_accepts_the_current_question() {
1064 let repo = TestSessionAccess::with_pending();
1065
1066 submit_pending_response_checked(&repo, respond_input(), Some("call-1".to_string()))
1067 .await
1068 .expect("matching identity should submit");
1069
1070 assert_eq!(repo.saves.load(Ordering::SeqCst), 1);
1071 assert!(repo.session.lock().unwrap().pending_question.is_none());
1072 }
1073
1074 #[tokio::test]
1075 async fn stale_tool_call_id_is_rejected_without_consuming_the_question() {
1076 let repo = TestSessionAccess::with_pending();
1077
1078 let error =
1079 submit_pending_response_checked(&repo, respond_input(), Some("stale-call".to_string()))
1080 .await
1081 .expect_err("stale identity must fail");
1082
1083 assert!(matches!(
1084 error,
1085 RespondError::PendingQuestionMismatch {
1086 ref expected,
1087 ref actual,
1088 } if expected == "stale-call" && actual == "call-1"
1089 ));
1090 assert_eq!(repo.saves.load(Ordering::SeqCst), 0);
1091 assert_eq!(
1092 repo.session
1093 .lock()
1094 .unwrap()
1095 .pending_question
1096 .as_ref()
1097 .map(|question| question.tool_call_id.as_str()),
1098 Some("call-1")
1099 );
1100 }
1101
1102 #[tokio::test]
1103 async fn omitted_tool_call_guard_remains_backwards_compatible() {
1104 let repo = TestSessionAccess::with_pending();
1105
1106 submit_pending_response(&repo, respond_input())
1107 .await
1108 .expect("legacy client should remain accepted");
1109
1110 assert_eq!(repo.saves.load(Ordering::SeqCst), 1);
1111 }
1112
1113 #[tokio::test]
1114 async fn concurrent_responses_consume_a_pending_question_once() {
1115 let repo = Arc::new(TestSessionAccess::with_pending_and_blocked_save(true));
1116
1117 let first_repo = repo.clone();
1118 let first = tokio::spawn(async move {
1119 submit_pending_response_checked(
1120 first_repo.as_ref(),
1121 respond_input(),
1122 Some("call-1".to_string()),
1123 )
1124 .await
1125 });
1126 repo.save_started.notified().await;
1127
1128 let second_entered = Arc::new(Notify::new());
1129 let second_repo = repo.clone();
1130 let second_entered_task = second_entered.clone();
1131 let second = tokio::spawn(async move {
1132 second_entered_task.notify_one();
1133 submit_pending_response_checked(
1134 second_repo.as_ref(),
1135 respond_input(),
1136 Some("call-1".to_string()),
1137 )
1138 .await
1139 });
1140 second_entered.notified().await;
1141 tokio::task::yield_now().await;
1142
1143 assert_eq!(
1144 repo.loads.load(Ordering::SeqCst),
1145 1,
1146 "the second responder must wait before loading the pending question"
1147 );
1148 repo.release_save.notify_one();
1149
1150 first.await.unwrap().expect("first response should win");
1151 let error = second
1152 .await
1153 .unwrap()
1154 .expect_err("second response must observe the consumed question");
1155 assert!(matches!(error, RespondError::NoPendingQuestion));
1156 assert_eq!(repo.loads.load(Ordering::SeqCst), 2);
1157 assert_eq!(repo.saves.load(Ordering::SeqCst), 1);
1158 }
1159
1160 #[tokio::test]
1161 async fn cancelled_response_waiter_does_not_leak_diagnostics_or_the_gate() {
1162 let session_id = "cancelled-response-waiter";
1163 let owner = acquire_pending_response_guard(session_id).await;
1164 let waiter = tokio::spawn(async move { acquire_pending_response_guard(session_id).await });
1165 tokio::time::timeout(std::time::Duration::from_secs(1), async {
1166 while pending_response_waiter_count(session_id) != 1 {
1167 tokio::task::yield_now().await;
1168 }
1169 })
1170 .await
1171 .expect("waiter should reach the gate");
1172
1173 waiter.abort();
1174 let _ = waiter.await;
1175 assert_eq!(pending_response_waiter_count(session_id), 0);
1176 drop(owner);
1177
1178 tokio::time::timeout(
1179 std::time::Duration::from_secs(1),
1180 acquire_pending_response_guard(session_id),
1181 )
1182 .await
1183 .expect("cancelled waiter must not retain the gate");
1184 }
1185
1186 #[test]
1187 fn enter_plan_mode_activates_plan_mode_state() {
1188 let mut session = Session::new("sess-1", "test-model");
1189 let pending = make_pending("EnterPlanMode");
1190
1191 apply_plan_mode_transition(&mut session, &pending, "Enter plan mode", None);
1192
1193 assert!(session.agent_runtime_state.is_some());
1194 let state = session.agent_runtime_state.unwrap();
1195 assert!(state.plan_mode.is_some());
1196 let plan = state.plan_mode.unwrap();
1197 assert_eq!(plan.status, PlanModeStatus::Exploring);
1198 assert_eq!(plan.pre_permission_mode, "default");
1199 }
1200
1201 #[test]
1202 fn enter_plan_mode_does_nothing_when_not_approved() {
1203 let mut session = Session::new("sess-1", "test-model");
1204 let pending = make_pending("EnterPlanMode");
1205
1206 apply_plan_mode_transition(&mut session, &pending, "Stay in normal mode", None);
1207
1208 assert!(session.agent_runtime_state.is_none());
1209 }
1210
1211 #[test]
1212 fn plan_mode_preserves_and_restores_typed_auto_request() {
1213 let mut session = Session::new("sess-auto-plan", "test-model");
1214 session
1215 .agent_runtime_state
1216 .get_or_insert_with(|| AgentRuntimeState::new("run-1"))
1217 .set_permission_mode(SessionPermissionMode::Auto);
1218 let enter = make_pending("EnterPlanMode");
1219 let transition =
1220 apply_plan_mode_transition(&mut session, &enter, "Enter plan mode", None).unwrap();
1221 assert!(matches!(
1222 transition,
1223 PlanModeTransition::Entered {
1224 ref pre_permission_mode,
1225 ..
1226 } if pre_permission_mode == "auto"
1227 ));
1228 assert_eq!(
1229 session
1230 .agent_runtime_state
1231 .as_ref()
1232 .unwrap()
1233 .plan_mode
1234 .as_ref()
1235 .unwrap()
1236 .pre_permission_mode,
1237 "auto"
1238 );
1239
1240 let exit = make_pending("ExitPlanMode");
1241 let transition = apply_plan_mode_transition(
1242 &mut session,
1243 &exit,
1244 "Approve (Auto mode)",
1245 Some("Reviewed plan".to_string()),
1246 )
1247 .unwrap();
1248 assert!(matches!(
1249 transition,
1250 PlanModeTransition::Exited {
1251 ref restored_mode,
1252 ..
1253 } if restored_mode == "auto"
1254 ));
1255 assert_eq!(
1256 session
1257 .agent_runtime_state
1258 .as_ref()
1259 .unwrap()
1260 .effective_permission_mode(),
1261 SessionPermissionMode::Auto
1262 );
1263 }
1264
1265 #[test]
1266 fn exit_plan_mode_does_not_restore_over_a_newer_typed_mode() {
1267 let mut session = Session::new("sess-plan-patch", "test-model");
1268 let state = session
1269 .agent_runtime_state
1270 .get_or_insert_with(|| AgentRuntimeState::new("run-1"));
1271 state.set_permission_mode(SessionPermissionMode::Auto);
1272 state.plan_mode = Some(PlanModeState {
1273 entered_at: Utc::now(),
1274 pre_permission_mode: "auto".to_string(),
1275 plan_file_path: None,
1276 status: PlanModeStatus::AwaitingApproval,
1277 });
1278 state.set_permission_mode(SessionPermissionMode::Bypass);
1280
1281 let transition = apply_plan_mode_transition(
1282 &mut session,
1283 &make_pending("ExitPlanMode"),
1284 "Approve (Default mode)",
1285 None,
1286 )
1287 .unwrap();
1288
1289 assert!(matches!(
1290 transition,
1291 PlanModeTransition::Exited {
1292 ref restored_mode,
1293 ..
1294 } if restored_mode == "bypass"
1295 ));
1296 let state = session.agent_runtime_state.unwrap();
1297 assert!(state.plan_mode.is_none());
1298 assert_eq!(
1299 state.effective_permission_mode(),
1300 SessionPermissionMode::Bypass
1301 );
1302 }
1303
1304 #[test]
1305 fn exit_plan_mode_clears_plan_mode_state() {
1306 let mut session = Session::new("sess-1", "test-model");
1307 session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
1308 session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
1309 entered_at: Utc::now(),
1310 pre_permission_mode: "default".to_string(),
1311 plan_file_path: None,
1312 status: PlanModeStatus::AwaitingApproval,
1313 });
1314 let pending = make_pending("ExitPlanMode");
1315
1316 apply_plan_mode_transition(
1317 &mut session,
1318 &pending,
1319 "Approve (Default mode)",
1320 Some("Reviewed plan".to_string()),
1321 );
1322
1323 assert!(session.agent_runtime_state.unwrap().plan_mode.is_none());
1324 }
1325
1326 #[test]
1327 fn exit_plan_mode_keeps_plan_mode_when_not_approved() {
1328 let mut session = Session::new("sess-1", "test-model");
1329 session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
1330 session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
1331 entered_at: Utc::now(),
1332 pre_permission_mode: "default".to_string(),
1333 plan_file_path: None,
1334 status: PlanModeStatus::AwaitingApproval,
1335 });
1336 let pending = make_pending("ExitPlanMode");
1337
1338 apply_plan_mode_transition(&mut session, &pending, "Stay in plan mode", None);
1339
1340 assert!(session.agent_runtime_state.unwrap().plan_mode.is_some());
1341 }
1342
1343 #[test]
1344 fn exit_plan_mode_ignores_other_tools() {
1345 let mut session = Session::new("sess-1", "test-model");
1346 let pending = make_pending("ConclusionWithOptions");
1347
1348 apply_plan_mode_transition(&mut session, &pending, "Approve", None);
1349
1350 assert!(session.agent_runtime_state.is_none());
1351 }
1352
1353 #[test]
1354 fn is_exit_plan_mode_approved_detects_approval() {
1355 assert!(is_exit_plan_mode_approved("Approve (Default mode)"));
1356 assert!(is_exit_plan_mode_approved("Approve (Accept edits mode)"));
1357 assert!(!is_exit_plan_mode_approved("Stay in plan mode"));
1358 assert!(!is_exit_plan_mode_approved("Edit plan first"));
1359 }
1360
1361 #[test]
1362 fn extract_exit_plan_from_tool_result_message_reads_plan_payload() {
1363 let mut session = Session::new("sess-1", "test-model");
1364 let mut tool_message = bamboo_agent_core::Message::tool_result(
1365 "call-1",
1366 serde_json::json!({
1367 "plan": "# Plan\n\n1. Step"
1368 })
1369 .to_string(),
1370 );
1371 tool_message.tool_success = Some(true);
1372 session.add_message(tool_message);
1373
1374 let plan = extract_exit_plan_from_tool_result_message(&session, "call-1");
1375 assert_eq!(plan.as_deref(), Some("# Plan\n\n1. Step"));
1376 }
1377
1378 #[test]
1379 fn selected_permission_preserves_typed_request_and_receipt_in_non_visible_metadata() {
1380 let mut session = Session::new("sess-1", "test-model");
1381 session.add_message(bamboo_agent_core::Message::tool_result(
1382 "permission-1",
1383 serde_json::json!({
1384 "status": "awaiting_permission_approval",
1385 "permission_request": {
1386 "request_id": "permission-1",
1387 "request_generation": "generation-1",
1388 "session_id": "sess-1",
1389 "allowed_decisions": ["allow_once", "deny_once"]
1390 }
1391 })
1392 .to_string(),
1393 ));
1394 session
1395 .messages
1396 .last_mut()
1397 .expect("permission tool result")
1398 .metadata = Some(serde_json::json!("legacy-metadata"));
1399
1400 assert!(update_or_append_tool_result_message(
1401 &mut session,
1402 "permission-1",
1403 "Approve",
1404 ResponseSource::Human,
1405 ));
1406 let receipt = PermissionDecisionReceipt {
1407 session_id: "sess-1".to_string(),
1408 decision: bamboo_tools::permission::PermissionDecision {
1409 request_id: "permission-1".to_string(),
1410 request_generation: "generation-1".to_string(),
1411 decision: bamboo_tools::permission::PermissionDecisionKind::AllowOnce,
1412 matcher_id: None,
1413 expected_policy_revision: Some(4),
1414 confirm_global: false,
1415 },
1416 decided_at: Utc::now(),
1417 };
1418 assert!(persist_permission_decision_receipt(
1419 &mut session,
1420 "permission-1",
1421 &receipt
1422 ));
1423
1424 let message = session
1425 .messages
1426 .iter()
1427 .find(|message| message.tool_call_id.as_deref() == Some("permission-1"))
1428 .expect("permission tool result");
1429 assert_eq!(message.content, "Selected response: Approve");
1430 assert_eq!(
1431 message
1432 .metadata
1433 .as_ref()
1434 .and_then(|metadata| metadata.get("permission_request"))
1435 .and_then(|request| request.get("request_id"))
1436 .and_then(serde_json::Value::as_str),
1437 Some("permission-1")
1438 );
1439 assert_eq!(
1440 message
1441 .metadata
1442 .as_ref()
1443 .and_then(|metadata| metadata.get("previous_metadata")),
1444 Some(&serde_json::json!("legacy-metadata"))
1445 );
1446 assert_eq!(
1447 message
1448 .metadata
1449 .as_ref()
1450 .and_then(|metadata| metadata.get("permission_decision_receipt"))
1451 .cloned()
1452 .and_then(|receipt| {
1453 serde_json::from_value::<PermissionDecisionReceipt>(receipt).ok()
1454 }),
1455 Some(receipt)
1456 );
1457 }
1458
1459 #[test]
1460 fn direct_parent_pending_cannot_be_answered_by_human_respond_text() {
1461 let mut session = Session::new("direct-parent-child", "test-model");
1462 session.set_pending_question_with_source(
1463 "question-call".into(),
1464 "AskUserQuestion".into(),
1465 "Which option?".into(),
1466 vec!["A".into(), "B".into()],
1467 true,
1468 bamboo_agent_core::PendingQuestionSource::DirectParent,
1469 );
1470 let input = RespondInput {
1471 session_id: session.id.clone(),
1472 user_response: "A".into(),
1473 model: None,
1474 model_ref: None,
1475 provider: None,
1476 reasoning_effort: None,
1477 };
1478 assert!(matches!(
1479 apply_pending_response(&mut session, &input, Some("question-call"), ResponseSource::Human, None),
1480 Err(RespondError::InvalidResponse(message)) if message.contains("Direct-parent")
1481 ));
1482 assert!(session.pending_question.is_some());
1483 }
1484
1485 #[test]
1486 fn reused_tool_call_id_requires_current_permission_generation() {
1487 let mut session = Session::new("sess-1", "test-model");
1488 for generation in ["generation-old", "generation-current"] {
1489 session.add_message(bamboo_agent_core::Message::tool_result(
1490 "permission-reused",
1491 serde_json::json!({
1492 "status": "awaiting_permission_approval",
1493 "permission_type": "execute_command",
1494 "resource": format!("resource-{generation}"),
1495 "permission_request": {
1496 "request_id": "permission-reused",
1497 "request_generation": generation,
1498 "session_id": "sess-1",
1499 "allowed_decisions": ["allow_once", "deny_once"]
1500 }
1501 })
1502 .to_string(),
1503 ));
1504 }
1505 session.set_pending_question_with_source(
1506 "permission-reused".to_string(),
1507 "Bash".to_string(),
1508 "Allow the current operation?".to_string(),
1509 vec!["Approve".to_string(), "Deny".to_string()],
1510 false,
1511 bamboo_agent_core::PendingQuestionSource::PauseTool,
1512 );
1513 let input = RespondInput {
1514 session_id: "sess-1".to_string(),
1515 user_response: "Approve".to_string(),
1516 model: None,
1517 model_ref: None,
1518 provider: None,
1519 reasoning_effort: None,
1520 };
1521 let receipt = |generation: &str| PermissionDecisionReceipt {
1522 session_id: "sess-1".to_string(),
1523 decision: bamboo_tools::permission::PermissionDecision {
1524 request_id: "permission-reused".to_string(),
1525 request_generation: generation.to_string(),
1526 decision: bamboo_tools::permission::PermissionDecisionKind::AllowOnce,
1527 matcher_id: None,
1528 expected_policy_revision: None,
1529 confirm_global: false,
1530 },
1531 decided_at: Utc::now(),
1532 };
1533
1534 let stale = receipt("generation-old");
1535 assert!(matches!(
1536 apply_pending_response(
1537 &mut session,
1538 &input,
1539 Some("permission-reused"),
1540 ResponseSource::Human,
1541 Some(&stale),
1542 ),
1543 Err(RespondError::InvalidResponse(message))
1544 if message.contains("generation")
1545 ));
1546 assert!(session.pending_question.is_some());
1547
1548 let current = receipt("generation-current");
1549 apply_pending_response(
1550 &mut session,
1551 &input,
1552 Some("permission-reused"),
1553 ResponseSource::Human,
1554 Some(¤t),
1555 )
1556 .expect("current generation resolves the parked operation");
1557 assert!(session.pending_question.is_none());
1558 assert_eq!(
1559 session
1560 .metadata
1561 .get(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY)
1562 .map(String::as_str),
1563 Some("generation-current")
1564 );
1565 assert!(session.messages[0].content.contains("generation-old"));
1566 assert_eq!(session.messages[1].content, "Selected response: Approve");
1567 assert_eq!(
1568 session.messages[1]
1569 .metadata
1570 .as_ref()
1571 .and_then(|metadata| metadata.get("permission_decision_receipt"))
1572 .and_then(|receipt| receipt.get("decision"))
1573 .and_then(|decision| decision.get("request_generation"))
1574 .and_then(serde_json::Value::as_str),
1575 Some("generation-current")
1576 );
1577 }
1578
1579 #[test]
1580 fn typed_permission_receipt_controls_replay_independent_of_display_options() {
1581 let session = || {
1582 let mut session = Session::new("sess-typed", "test-model");
1583 session.add_message(bamboo_agent_core::Message::tool_result(
1584 "permission-localized",
1585 serde_json::json!({
1586 "status": "awaiting_permission_approval",
1587 "permission_type": "execute_command",
1588 "resource": "cargo test --workspace",
1589 "permission_request": {
1590 "request_id": "permission-localized",
1591 "request_generation": "generation-localized",
1592 "session_id": "sess-typed",
1593 "allowed_decisions": ["allow_once", "deny_once"]
1594 }
1595 })
1596 .to_string(),
1597 ));
1598 session.set_pending_question_with_source(
1599 "permission-localized".to_string(),
1600 "Bash".to_string(),
1601 "允许执行吗?".to_string(),
1602 vec!["允许".to_string(), "拒绝".to_string()],
1603 false,
1604 bamboo_agent_core::PendingQuestionSource::PauseTool,
1605 );
1606 session
1607 };
1608 let receipt = |decision| PermissionDecisionReceipt {
1609 session_id: "sess-typed".to_string(),
1610 decision: bamboo_tools::permission::PermissionDecision {
1611 request_id: "permission-localized".to_string(),
1612 request_generation: "generation-localized".to_string(),
1613 decision,
1614 matcher_id: None,
1615 expected_policy_revision: None,
1616 confirm_global: false,
1617 },
1618 decided_at: Utc::now(),
1619 };
1620
1621 let mut allowed = session();
1622 let allow_input = RespondInput {
1623 session_id: "sess-typed".to_string(),
1624 user_response: "已由结构化决定允许".to_string(),
1625 model: None,
1626 model_ref: None,
1627 provider: None,
1628 reasoning_effort: None,
1629 };
1630 apply_pending_response(
1631 &mut allowed,
1632 &allow_input,
1633 Some("permission-localized"),
1634 ResponseSource::Human,
1635 Some(&receipt(PermissionDecisionKind::AllowOnce)),
1636 )
1637 .expect("typed allow must not depend on localized display options");
1638 assert_eq!(
1639 allowed
1640 .metadata
1641 .get(PERMISSION_REEXECUTE_METADATA_KEY)
1642 .map(String::as_str),
1643 Some("permission-localized")
1644 );
1645 assert_eq!(
1646 allowed
1647 .metadata
1648 .get(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY)
1649 .map(String::as_str),
1650 Some("generation-localized")
1651 );
1652
1653 let mut denied = session();
1654 denied.metadata.insert(
1655 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1656 "stale-call".to_string(),
1657 );
1658 denied.metadata.insert(
1659 PERMISSION_REEXECUTE_GENERATION_METADATA_KEY.to_string(),
1660 "stale-generation".to_string(),
1661 );
1662 let deny_input = RespondInput {
1663 session_id: "sess-typed".to_string(),
1664 user_response: "Approve".to_string(),
1666 model: None,
1667 model_ref: None,
1668 provider: None,
1669 reasoning_effort: None,
1670 };
1671 apply_pending_response(
1672 &mut denied,
1673 &deny_input,
1674 Some("permission-localized"),
1675 ResponseSource::Human,
1676 Some(&receipt(PermissionDecisionKind::DenyOnce)),
1677 )
1678 .expect("typed deny must not depend on display text");
1679 assert!(!denied
1680 .metadata
1681 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
1682 assert!(!denied
1683 .metadata
1684 .contains_key(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY));
1685
1686 let mut legacy = session();
1687 assert!(matches!(
1688 apply_pending_response(
1689 &mut legacy,
1690 &allow_input,
1691 Some("permission-localized"),
1692 ResponseSource::Human,
1693 None,
1694 ),
1695 Err(RespondError::InvalidResponse(_))
1696 ));
1697 assert!(legacy.pending_question.is_some());
1698 }
1699}
1700
1701#[cfg(test)]
1702mod no_progress_tests;
1703
1704#[cfg(test)]
1705mod receipt_persistence_tests {
1706 use super::*;
1707 use crate::session_app::approval_replay::{
1708 find_permission_replay_target, restore_permission_replay_authorization,
1709 };
1710 use crate::{read_cached_session, SessionCache, SessionRepository};
1711 use bamboo_agent_core::storage::Storage;
1712 use bamboo_agent_core::tools::{FunctionCall, ToolCall};
1713 use bamboo_storage::{LockedSessionStore, SessionStoreV2};
1714 use bamboo_tools::permission::{
1715 PermissionConfig, PermissionDecision, PermissionMode, PermissionReasonCode,
1716 PermissionRequest, RiskLevel,
1717 };
1718
1719 fn pending_permission(
1720 session_id: &str,
1721 ) -> (Session, PermissionRequest, PermissionDecisionReceipt) {
1722 let mut session = Session::new(session_id, "test-model");
1723 session.agent_runtime_state = Some(AgentRuntimeState::new("receipt-test-run"));
1724 let request = |generation: &str, resource: &str| PermissionRequest {
1725 request_id: "permission-reused".into(),
1726 request_generation: generation.into(),
1727 session_id: session_id.into(),
1728 workspace_path: None,
1729 tool_name: "Bash".into(),
1730 permission_type: PermissionType::ExecuteCommand,
1731 resource: resource.into(),
1732 operation_summary: format!("execute {resource}"),
1733 risk_level: RiskLevel::High,
1734 reason_code: PermissionReasonCode::RiskThreshold,
1735 effective_mode: PermissionMode::Default,
1736 bypass_requested: false,
1737 auto_approve_requested: false,
1738 policy_revision: 4,
1739 matched_rule: None,
1740 allowed_decisions: vec![
1741 PermissionDecisionKind::AllowOnce,
1742 PermissionDecisionKind::DenyOnce,
1743 ],
1744 suggested_matchers: vec![],
1745 };
1746 let current = request("generation-current", "current-command");
1747 for (name, request) in [
1748 ("old", request("generation-old", "old-command")),
1749 ("current", current.clone()),
1750 ] {
1751 session.add_message(Message::assistant(
1752 "",
1753 Some(vec![ToolCall {
1754 id: request.request_id.clone(),
1755 tool_type: "function".into(),
1756 function: FunctionCall {
1757 name: request.tool_name.clone(),
1758 arguments: serde_json::json!({"command": request.resource}).to_string(),
1759 },
1760 }]),
1761 ));
1762 let mut result = Message::tool_result_with_status(
1763 &request.request_id,
1764 serde_json::json!({
1765 "status": "awaiting_permission_approval",
1766 "permission_type": request.permission_type,
1767 "resource": request.resource,
1768 "permission_request": request,
1769 })
1770 .to_string(),
1771 false,
1772 );
1773 result.id = format!("result-{name}");
1774 session.add_message(result);
1775 }
1776 session.set_pending_question_with_source(
1777 current.request_id.clone(),
1778 current.tool_name.clone(),
1779 "允许本次操作?".into(),
1780 vec!["允许".into(), "拒绝".into()],
1781 false,
1782 bamboo_agent_core::PendingQuestionSource::PauseTool,
1783 );
1784 session.metadata.insert(
1785 "runtime.suspend_reason".into(),
1786 "awaiting_clarification".into(),
1787 );
1788 let receipt = PermissionDecisionReceipt {
1789 session_id: session_id.into(),
1790 decision: PermissionDecision {
1791 request_id: current.request_id.clone(),
1792 request_generation: current.request_generation.clone(),
1793 decision: PermissionDecisionKind::AllowOnce,
1794 matcher_id: None,
1795 expected_policy_revision: Some(current.policy_revision),
1796 confirm_global: false,
1797 },
1798 decided_at: Utc::now(),
1799 };
1800 (session, current, receipt)
1801 }
1802
1803 async fn repository(
1804 session: &mut Session,
1805 ) -> (tempfile::TempDir, Arc<SessionStoreV2>, SessionRepository) {
1806 let directory = tempfile::tempdir().unwrap();
1807 let store = Arc::new(
1808 SessionStoreV2::new(directory.path().to_path_buf())
1809 .await
1810 .unwrap(),
1811 );
1812 let storage: Arc<dyn Storage> = store.clone();
1813 let repo = SessionRepository::new(
1814 SessionCache::default(),
1815 storage.clone(),
1816 Arc::new(LockedSessionStore::new(storage)),
1817 );
1818 repo.save(session).await.unwrap();
1819 (directory, store, repo)
1820 }
1821
1822 fn input(session_id: &str) -> RespondInput {
1823 RespondInput {
1824 session_id: session_id.into(),
1825 user_response: "允许".into(),
1826 model: None,
1827 model_ref: None,
1828 provider: None,
1829 reasoning_effort: None,
1830 }
1831 }
1832
1833 #[tokio::test]
1834 async fn typed_response_round_trip_restores_only_the_exact_allow_once() {
1835 eprintln!("debug_assertions={}", cfg!(debug_assertions));
1836 let (mut session, request, receipt) = pending_permission("receipt-round-trip");
1837 let (_directory, store, repo) = repository(&mut session).await;
1838 let old_occurrence = serde_json::to_value(&session.messages[..2]).unwrap();
1839 let guard = acquire_pending_response_guard(&session.id).await;
1840 let (accepted, _, _, _) = submit_pending_permission_response_checked_guarded(
1841 &repo,
1842 input(&session.id),
1843 Some(request.request_id.clone()),
1844 receipt.clone(),
1845 &guard,
1846 )
1847 .await
1848 .expect("public typed response accepts the current occurrence");
1849 let durable = store.load_session(&session.id).await.unwrap().unwrap();
1850 let restarted: Session =
1851 serde_json::from_slice(&serde_json::to_vec(&durable).unwrap()).unwrap();
1852 assert!(accepted.pending_question.is_none() && restarted.pending_question.is_none());
1853 assert_eq!(
1854 serde_json::to_value(&restarted.messages[..2]).unwrap(),
1855 old_occurrence
1856 );
1857 let message = restarted
1858 .messages
1859 .iter()
1860 .find(|message| message.id == "result-current")
1861 .unwrap();
1862 assert_eq!(
1863 message.tool_call_id.as_deref(),
1864 Some(request.request_id.as_str())
1865 );
1866 assert_eq!(message.content, "Selected response: 允许");
1867 assert_eq!(message.tool_success, Some(true));
1868 let metadata = message
1869 .metadata
1870 .as_ref()
1871 .expect("preserved typed request metadata");
1872 assert_eq!(
1873 metadata.get("permission_request"),
1874 Some(&serde_json::to_value(&request).unwrap())
1875 );
1876 assert_eq!(
1877 metadata.get("permission_decision_receipt"),
1878 Some(&serde_json::to_value(&receipt).unwrap()),
1879 "the public response must persist the complete receipt, including decided_at"
1880 );
1881 assert_eq!(
1882 restarted.metadata.get(PERMISSION_REEXECUTE_METADATA_KEY),
1883 Some(&request.request_id)
1884 );
1885 assert_eq!(
1886 restarted
1887 .metadata
1888 .get(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY),
1889 Some(&request.request_generation)
1890 );
1891
1892 let target = find_permission_replay_target(
1893 &restarted,
1894 &request.request_id,
1895 Some(&request.request_generation),
1896 )
1897 .expect("replay resolves the exact current occurrence after reload");
1898 assert_eq!(
1899 target.request_generation(),
1900 Some(request.request_generation.as_str())
1901 );
1902 assert_eq!(
1903 target.tool_call().function.arguments,
1904 serde_json::json!({"command":"current-command"}).to_string()
1905 );
1906 let config = PermissionConfig::new();
1907 restore_permission_replay_authorization(&config, &restarted, &target, "Bash").unwrap();
1908 for (session_id, call_id, generation, resource) in [
1911 (
1912 restarted.id.as_str(),
1913 request.request_id.as_str(),
1914 "generation-old",
1915 request.resource.as_str(),
1916 ),
1917 (
1918 restarted.id.as_str(),
1919 request.request_id.as_str(),
1920 request.request_generation.as_str(),
1921 "other-command",
1922 ),
1923 (
1924 "other-session",
1925 request.request_id.as_str(),
1926 request.request_generation.as_str(),
1927 request.resource.as_str(),
1928 ),
1929 ] {
1930 assert!(!config.consume_once_for_generation(
1931 session_id,
1932 call_id,
1933 generation,
1934 request.permission_type,
1935 resource
1936 ));
1937 }
1938 assert!(config.consume_once_for_generation(
1939 &restarted.id,
1940 &request.request_id,
1941 &request.request_generation,
1942 request.permission_type,
1943 &request.resource
1944 ));
1945 assert!(!config.consume_once_for_generation(
1946 &restarted.id,
1947 &request.request_id,
1948 &request.request_generation,
1949 request.permission_type,
1950 &request.resource
1951 ));
1952 }
1953
1954 #[tokio::test]
1955 async fn typed_deny_round_trip_retains_receipt_without_grants_or_replay() {
1956 eprintln!("debug_assertions={}", cfg!(debug_assertions));
1957 let (mut session, request, mut receipt) = pending_permission("receipt-deny");
1958 receipt.decision.decision = PermissionDecisionKind::DenyOnce;
1959 session.metadata.insert(
1960 PERMISSION_REEXECUTE_METADATA_KEY.into(),
1961 "stale-call".into(),
1962 );
1963 session.metadata.insert(
1964 PERMISSION_REEXECUTE_GENERATION_METADATA_KEY.into(),
1965 "stale-generation".into(),
1966 );
1967 let (_directory, store, repo) = repository(&mut session).await;
1968 let old_occurrence = serde_json::to_value(&session.messages[..2]).unwrap();
1969 let mut response = input(&session.id);
1970 response.user_response = "Approve".into();
1971 let guard = acquire_pending_response_guard(&session.id).await;
1972 let (_, display_response, _, grants) = submit_pending_permission_response_checked_guarded(
1973 &repo,
1974 response,
1975 Some(request.request_id.clone()),
1976 receipt.clone(),
1977 &guard,
1978 )
1979 .await
1980 .expect("typed deny accepts display text without granting permission");
1981 assert_eq!(display_response, "Approve");
1982 assert!(grants.is_empty());
1983 let durable = store.load_session(&session.id).await.unwrap().unwrap();
1984 let restarted: Session =
1985 serde_json::from_slice(&serde_json::to_vec(&durable).unwrap()).unwrap();
1986 assert!(restarted.pending_question.is_none());
1987 assert_eq!(
1988 serde_json::to_value(&restarted.messages[..2]).unwrap(),
1989 old_occurrence
1990 );
1991 let message = restarted.messages.last().unwrap();
1992 assert_eq!(message.id, "result-current");
1993 assert_eq!(message.content, "Selected response: Approve");
1994 let metadata = message.metadata.as_ref().unwrap();
1995 assert_eq!(
1996 metadata.get("permission_request"),
1997 Some(&serde_json::to_value(&request).unwrap())
1998 );
1999 assert_eq!(
2000 metadata.get("permission_decision_receipt"),
2001 Some(&serde_json::to_value(&receipt).unwrap())
2002 );
2003 assert!(!restarted
2004 .metadata
2005 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
2006 assert!(!restarted
2007 .metadata
2008 .contains_key(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY));
2009 }
2010
2011 #[tokio::test]
2012 async fn failed_typed_receipt_does_not_publish_a_partial_response() {
2013 eprintln!("debug_assertions={}", cfg!(debug_assertions));
2014 let (mut session, request, receipt) = pending_permission("receipt-conflict");
2015 let current = session.messages.last_mut().unwrap();
2016 current.metadata = Some(serde_json::json!({"permission_request": request}));
2017 let mut payload: serde_json::Value = serde_json::from_str(¤t.content).unwrap();
2018 payload["permission_request"]["request_generation"] =
2019 "generation-conflicting-payload".into();
2020 current.content = payload.to_string();
2021 let (_directory, store, repo) = repository(&mut session).await;
2022 let durable_before = store.load_session(&session.id).await.unwrap().unwrap();
2023 assert_eq!(
2027 permission_request_generation(durable_before.messages.last().unwrap()).as_deref(),
2028 Some(receipt.decision.request_generation.as_str())
2029 );
2030 let cache_before =
2031 serde_json::to_value(read_cached_session(repo.cache(), &session.id).unwrap()).unwrap();
2032 let durable_before = serde_json::to_value(durable_before).unwrap();
2033 let guard = acquire_pending_response_guard(&session.id).await;
2034 let result = submit_pending_permission_response_checked_guarded(
2035 &repo,
2036 input(&session.id),
2037 Some(request.request_id),
2038 receipt,
2039 &guard,
2040 )
2041 .await;
2042 assert!(
2043 matches!(result, Err(RespondError::InvalidResponse(ref message)) if message.contains("receipt")),
2044 "failed receipt persistence must be an explicit response error: {result:?}"
2045 );
2046 assert_eq!(
2047 serde_json::to_value(store.load_session(&session.id).await.unwrap().unwrap()).unwrap(),
2048 durable_before,
2049 "pending question, result and all durable markers stay unchanged"
2050 );
2051 assert_eq!(
2052 serde_json::to_value(read_cached_session(repo.cache(), &session.id).unwrap()).unwrap(),
2053 cache_before,
2054 "a rejected response must not publish a cache snapshot"
2055 );
2056 }
2057
2058 #[tokio::test]
2059 async fn payload_cannot_smuggle_frozen_operation_through_a_typed_answer() {
2060 let (mut session, request, receipt) = pending_permission("frozen-payload-refusal");
2061 let current = session.messages.last_mut().unwrap();
2062 let mut payload: serde_json::Value = serde_json::from_str(¤t.content).unwrap();
2063 payload[bamboo_agent_core::tools::PENDING_TOOL_OPERATION_METADATA_KEY] =
2064 serde_json::json!({"version":1});
2065 current.content = payload.to_string();
2066 let (_directory, store, repo) = repository(&mut session).await;
2067 let before =
2068 serde_json::to_value(store.load_session(&session.id).await.unwrap().unwrap()).unwrap();
2069 let guard = acquire_pending_response_guard(&session.id).await;
2070 let result = submit_pending_permission_response_checked_guarded(
2071 &repo,
2072 input(&session.id),
2073 Some(request.request_id),
2074 receipt,
2075 &guard,
2076 )
2077 .await;
2078 assert!(
2079 matches!(result, Err(RespondError::InvalidResponse(_))),
2080 "{result:?}"
2081 );
2082 assert_eq!(
2083 serde_json::to_value(store.load_session(&session.id).await.unwrap().unwrap()).unwrap(),
2084 before
2085 );
2086 assert_eq!(
2087 serde_json::to_value(read_cached_session(repo.cache(), &session.id).unwrap()).unwrap(),
2088 before
2089 );
2090 }
2091}