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 let pending = session
414 .pending_question
415 .take()
416 .ok_or(RespondError::NoPendingQuestion)?;
417
418 if pending.source == bamboo_agent_core::PendingQuestionSource::DirectParent {
419 session.pending_question = Some(pending);
420 return Err(RespondError::InvalidResponse(
421 "Direct-parent clarification requires a canonical ParentRequest reply".into(),
422 ));
423 }
424
425 if session
426 .messages
427 .iter()
428 .rev()
429 .find(|message| message.tool_call_id.as_deref() == Some(pending.tool_call_id.as_str()))
430 .is_some_and(result_payload_has_supervisor_authority)
431 {
432 session.pending_question = Some(pending);
433 return Err(RespondError::InvalidResponse(
434 "Supervisor authority cannot originate in a tool result payload".into(),
435 ));
436 }
437
438 if let Some(expected) = expected_tool_call_id {
439 if pending.tool_call_id != expected {
440 let actual = pending.tool_call_id.clone();
441 session.pending_question = Some(pending);
442 return Err(RespondError::PendingQuestionMismatch {
443 expected: expected.to_string(),
444 actual,
445 });
446 }
447 }
448
449 if let Some(receipt) = permission_receipt {
450 if receipt.session_id != input.session_id
451 || receipt.decision.request_id != pending.tool_call_id
452 {
453 session.pending_question = Some(pending);
454 return Err(RespondError::InvalidResponse(
455 "permission receipt identity does not match the pending question".to_string(),
456 ));
457 }
458 let current_generation = session
459 .messages
460 .iter()
461 .rev()
462 .find(|message| message.tool_call_id.as_deref() == Some(pending.tool_call_id.as_str()))
463 .and_then(permission_request_generation);
464 if current_generation.as_deref() != Some(receipt.decision.request_generation.as_str()) {
465 session.pending_question = Some(pending);
466 return Err(RespondError::InvalidResponse(
467 "permission receipt generation does not match the pending operation".to_string(),
468 ));
469 }
470 }
471
472 if permission_receipt.is_none() {
476 if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
477 session.pending_question = Some(pending);
479 return Err(RespondError::InvalidResponse(error_message));
480 }
481 }
482
483 let no_progress_question = super::no_progress::is_no_progress_question(session, &pending);
484 if no_progress_question
485 && (response_source != ResponseSource::Human || permission_receipt.is_some())
486 {
487 session.pending_question = Some(pending);
488 return Err(RespondError::InvalidResponse(
489 "A progress pause requires a Human response".into(),
490 ));
491 }
492
493 let tool_call_id = pending.tool_call_id.clone();
494 tracing::debug!(
495 "[{}] Looking for tool result message with tool_call_id: {}",
496 input.session_id,
497 tool_call_id
498 );
499
500 let reviewed_plan = extract_exit_plan_from_tool_result_message(session, &tool_call_id);
501
502 let typed_permission_approved = permission_receipt.is_some_and(|receipt| {
506 matches!(
507 receipt.decision.decision,
508 PermissionDecisionKind::AllowOnce
509 | PermissionDecisionKind::AllowSession
510 | PermissionDecisionKind::AllowWorkspace
511 | PermissionDecisionKind::AllowGlobal
512 )
513 });
514 let permission_approved = permission_receipt
515 .map(|_| typed_permission_approved)
516 .unwrap_or_else(|| is_permission_approval(&input.user_response));
517 let permission_grants = if permission_approved {
518 extract_permission_grants_from_tool_result_message(session, &tool_call_id)
519 } else {
520 Vec::new()
521 };
522 let should_reexecute = permission_receipt
523 .map(|_| typed_permission_approved)
524 .unwrap_or(!permission_grants.is_empty());
525 if should_reexecute {
526 session.metadata.insert(
530 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
531 tool_call_id.clone(),
532 );
533 if let Some(receipt) = permission_receipt {
534 session.metadata.insert(
535 PERMISSION_REEXECUTE_GENERATION_METADATA_KEY.to_string(),
536 receipt.decision.request_generation.clone(),
537 );
538 } else {
539 session
540 .metadata
541 .remove(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY);
542 }
543 } else if permission_receipt.is_some() {
544 session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
546 session
547 .metadata
548 .remove(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY);
549 }
550
551 let found = if no_progress_question {
553 let mut direction = Message::user(input.user_response.clone());
556 if input.user_response == super::no_progress::CONTINUE_OPTION {
557 direction.metadata = Some(serde_json::json!({"runtime_kind": "no_progress_continue"}));
558 }
559 session.add_message(direction);
560 true
561 } else {
562 update_or_append_tool_result_message(
563 session,
564 &tool_call_id,
565 &input.user_response,
566 response_source,
567 )
568 };
569 if let Some(receipt) = permission_receipt {
570 if !persist_permission_decision_receipt(session, &tool_call_id, receipt) {
571 return Err(RespondError::InvalidResponse(
572 "permission receipt could not be persisted for the pending operation".to_string(),
573 ));
574 }
575 }
576 if found {
577 tracing::info!(
578 "[{}] Updated existing tool result message",
579 input.session_id
580 );
581 } else {
582 tracing::warn!(
583 "[{}] Tool result message not found for tool_call_id: {}, added fallback message",
584 input.session_id,
585 tool_call_id
586 );
587 }
588
589 let plan_mode_transition =
591 apply_plan_mode_transition(session, &pending, &input.user_response, reviewed_plan);
592
593 session.clear_pending_question();
595 record_consumed_clarification(session, &tool_call_id);
596 session.metadata.remove("runtime.suspend_reason");
597 if no_progress_question && input.user_response == super::no_progress::STOP_OPTION {
598 super::no_progress::stop_after_response(session);
599 return Ok((input.user_response.clone(), None, Vec::new()));
600 }
601 session.metadata.insert(
602 CLARIFICATION_RESUME_PENDING_KEY.to_string(),
603 "true".to_string(),
604 );
605 session.metadata.insert(
606 CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY.to_string(),
607 "true".to_string(),
608 );
609 mark_startup_handoff(session);
610
611 let request_model_ref = derive_model_ref(
613 input.model_ref.as_ref(),
614 input.provider.as_deref(),
615 input.model.as_deref(),
616 );
617 if let Some(model_ref) = request_model_ref.as_ref() {
618 persist_model_ref(session, model_ref);
619 } else {
620 persist_legacy_model_provider(session, input.model.as_deref(), input.provider.as_deref());
621 }
622 if let Some(reasoning_effort) = input.reasoning_effort {
623 session.reasoning_effort = Some(reasoning_effort);
624 }
625
626 Ok((
627 input.user_response.clone(),
628 plan_mode_transition,
629 permission_grants,
630 ))
631}
632
633fn record_consumed_clarification(session: &mut Session, tool_call_id: &str) {
634 let mut legacy_consumed = session
635 .metadata
636 .get(CONSUMED_CLARIFICATION_IDS_KEY)
637 .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
638 .unwrap_or_default();
639 legacy_consumed.retain(|existing| existing != tool_call_id);
640 legacy_consumed.push(tool_call_id.to_string());
641 if legacy_consumed.len() > 64 {
642 legacy_consumed.drain(..legacy_consumed.len() - 64);
643 }
644 if let Ok(serialized) = serde_json::to_string(&legacy_consumed) {
645 session
646 .metadata
647 .insert(CONSUMED_CLARIFICATION_IDS_KEY.to_string(), serialized);
648 }
649
650 let Some(occurrence) = latest_response_occurrence(session, tool_call_id) else {
651 return;
652 };
653 let mut consumed = session
654 .metadata
655 .get(CONSUMED_RESPONSE_OCCURRENCES_KEY)
656 .and_then(|value| serde_json::from_str::<Vec<ResponseOccurrence>>(value).ok())
657 .unwrap_or_default();
658 consumed.retain(|existing| existing != &occurrence);
659 consumed.push(occurrence);
660 if consumed.len() > 64 {
661 consumed.drain(..consumed.len() - 64);
662 }
663 if let Ok(serialized) = serde_json::to_string(&consumed) {
664 session
665 .metadata
666 .insert(CONSUMED_RESPONSE_OCCURRENCES_KEY.to_string(), serialized);
667 }
668}
669
670fn apply_plan_mode_transition(
672 session: &mut Session,
673 pending: &PendingQuestion,
674 user_response: &str,
675 reviewed_plan: Option<String>,
676) -> Option<PlanModeTransition> {
677 match pending.tool_name.as_str() {
678 "EnterPlanMode" if user_response.to_lowercase().contains("enter plan mode") => {
679 let pre_mode = session
680 .agent_runtime_state
681 .as_ref()
682 .map(|state| state.effective_permission_mode().as_str().to_string())
683 .unwrap_or_else(|| SessionPermissionMode::Default.as_str().to_string());
684
685 let entered_at = Utc::now();
686 let status = PlanModeStatus::Exploring;
687 let runtime_state = session
688 .agent_runtime_state
689 .get_or_insert_with(|| AgentRuntimeState::new(uuid::Uuid::new_v4().to_string()));
690 runtime_state.plan_mode = Some(PlanModeState {
691 entered_at,
692 pre_permission_mode: pre_mode.clone(),
693 plan_file_path: None,
694 status,
695 });
696 tracing::info!(
697 session_id = %session.id,
698 "Entered plan mode"
699 );
700 Some(PlanModeTransition::Entered {
701 reason: Some(pending.question.clone()),
702 pre_permission_mode: pre_mode,
703 entered_at,
704 status,
705 plan_file_path: None,
706 })
707 }
708 "ExitPlanMode" if is_exit_plan_mode_approved(user_response) => {
709 let restored_mode = session
710 .agent_runtime_state
711 .as_ref()
712 .map(|state| state.effective_permission_mode().as_str().to_string())
713 .unwrap_or_else(|| "default".to_string());
714 if let Some(ref mut runtime_state) = session.agent_runtime_state {
715 runtime_state.plan_mode = None;
720 }
721 tracing::info!(
722 session_id = %session.id,
723 "Exited plan mode"
724 );
725 Some(PlanModeTransition::Exited {
726 approved: true,
727 restored_mode,
728 plan: reviewed_plan,
729 })
730 }
731 _ => None,
732 }
733}
734
735fn is_exit_plan_mode_approved(user_response: &str) -> bool {
737 let lower = user_response.to_lowercase();
738 lower.contains("approve") && !lower.contains("stay in plan mode")
739}
740
741pub fn validate_pending_response(
744 pending: &PendingQuestion,
745 user_response: &str,
746) -> Result<(), String> {
747 if pending.allow_custom {
748 return Ok(());
749 }
750
751 let valid = pending.options.iter().any(|option| option == user_response);
752 if valid {
753 Ok(())
754 } else {
755 let options_str = pending.options.join(", ");
756 Err(format!("Response must be one of: {options_str}"))
757 }
758}
759
760pub fn update_or_append_tool_result_message(
761 session: &mut Session,
762 tool_call_id: &str,
763 user_response: &str,
764 response_source: ResponseSource,
765) -> bool {
766 for message in session.messages.iter_mut().rev() {
767 if message.tool_call_id.as_deref() == Some(tool_call_id) {
768 if result_payload_has_supervisor_authority(message) {
771 return false;
772 }
773 if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&message.content) {
779 if payload.get("status").and_then(serde_json::Value::as_str)
780 == Some("awaiting_permission_approval")
781 {
782 if let Some(request) = payload.get("permission_request") {
783 insert_message_metadata(message, "permission_request", request.clone());
784 }
785 }
786 }
787 message.content = selected_message_content(user_response, response_source);
788 message.tool_success = Some(true);
789 return true;
790 }
791 }
792
793 session.add_message(bamboo_agent_core::Message::tool_result_with_status(
794 tool_call_id,
795 selected_message_content(user_response, response_source),
796 true,
797 ));
798 false
799}
800
801fn result_payload_has_supervisor_authority(message: &Message) -> bool {
802 serde_json::from_str::<serde_json::Value>(&message.content).ok().is_some_and(|payload| {
803 payload.get(bamboo_agent_core::tools::ExecutingSupervisorObservation::PERMISSION_REPLAY_METADATA_KEY).is_some()
804 })
805}
806
807fn insert_message_metadata(message: &mut Message, key: &str, value: serde_json::Value) {
808 let metadata = message
809 .metadata
810 .get_or_insert_with(|| serde_json::Value::Object(Default::default()));
811 if !metadata.is_object() {
812 let previous = std::mem::replace(metadata, serde_json::Value::Object(Default::default()));
813 metadata
814 .as_object_mut()
815 .expect("replacement metadata is an object")
816 .insert("previous_metadata".to_string(), previous);
817 }
818 metadata
819 .as_object_mut()
820 .expect("message metadata is an object")
821 .insert(key.to_string(), value);
822}
823
824fn permission_request_generation(message: &Message) -> Option<String> {
825 message
826 .metadata
827 .as_ref()
828 .and_then(|metadata| metadata.get("permission_request"))
829 .and_then(|request| request.get("request_generation"))
830 .and_then(serde_json::Value::as_str)
831 .map(ToOwned::to_owned)
832 .or_else(|| {
833 serde_json::from_str::<serde_json::Value>(&message.content)
834 .ok()?
835 .get("permission_request")?
836 .get("request_generation")?
837 .as_str()
838 .map(ToOwned::to_owned)
839 })
840}
841
842fn persist_permission_decision_receipt(
843 session: &mut Session,
844 tool_call_id: &str,
845 receipt: &PermissionDecisionReceipt,
846) -> bool {
847 let Some(message) = session
848 .messages
849 .iter_mut()
850 .rev()
851 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))
852 else {
853 return false;
854 };
855 if permission_request_generation(message).as_deref()
856 != Some(receipt.decision.request_generation.as_str())
857 {
858 return false;
859 }
860 insert_message_metadata(
861 message,
862 "permission_decision_receipt",
863 serde_json::to_value(receipt).expect("permission receipt is serializable"),
864 );
865 true
866}
867
868fn selected_message_content(user_response: &str, response_source: ResponseSource) -> String {
869 match response_source {
870 ResponseSource::Human => format!("Selected response: {}", user_response),
871 ResponseSource::Gold => format!("Auto-selected response (gold): {}", user_response),
872 }
873}
874
875fn extract_exit_plan_from_tool_result_message(
876 session: &Session,
877 tool_call_id: &str,
878) -> Option<String> {
879 let message = session
880 .messages
881 .iter()
882 .rev()
883 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))?;
884 let payload = serde_json::from_str::<serde_json::Value>(&message.content).ok()?;
885 payload
886 .get("plan")
887 .and_then(|value| value.as_str())
888 .map(str::trim)
889 .filter(|value| !value.is_empty())
890 .map(ToOwned::to_owned)
891}
892
893fn is_permission_approval(user_response: &str) -> bool {
898 user_response.trim().eq_ignore_ascii_case("approve")
899}
900
901fn extract_permission_grants_from_tool_result_message(
910 session: &Session,
911 tool_call_id: &str,
912) -> Vec<(PermissionType, String)> {
913 let message = match session
914 .messages
915 .iter()
916 .rev()
917 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))
918 {
919 Some(message) => message,
920 None => return Vec::new(),
921 };
922 let payload = match serde_json::from_str::<serde_json::Value>(&message.content) {
923 Ok(payload) => payload,
924 Err(_) => return Vec::new(),
925 };
926 if payload.get("status").and_then(|value| value.as_str())
927 != Some("awaiting_permission_approval")
928 {
929 return Vec::new();
930 }
931
932 let parse_one = |value: &serde_json::Value| -> Option<(PermissionType, String)> {
933 let type_value = value
934 .get("permission_type")
935 .or_else(|| value.get("type"))?
936 .clone();
937 let perm_type: PermissionType = serde_json::from_value(type_value).ok()?;
938 let resource = value.get("resource")?.as_str()?.trim().to_string();
939 if resource.is_empty() {
940 return None;
941 }
942 Some((perm_type, resource))
943 };
944
945 if let Some(array) = payload
946 .get("permissions")
947 .and_then(|value| value.as_array())
948 {
949 array.iter().filter_map(parse_one).collect()
950 } else {
951 parse_one(&payload).into_iter().collect()
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
959 use std::sync::Mutex;
960 use tokio::sync::Notify;
961
962 struct TestSessionAccess {
963 session: Mutex<Session>,
964 loads: AtomicUsize,
965 saves: AtomicUsize,
966 block_first_save: AtomicBool,
967 save_started: Notify,
968 release_save: Notify,
969 }
970
971 impl TestSessionAccess {
972 fn with_pending() -> Self {
973 Self::with_pending_and_blocked_save(false)
974 }
975
976 fn with_pending_and_blocked_save(block_first_save: bool) -> Self {
977 let mut session = Session::new("sess-1", "test-model");
978 session.pending_question = Some(make_pending("ConclusionWithOptions"));
979 Self {
980 session: Mutex::new(session),
981 loads: AtomicUsize::new(0),
982 saves: AtomicUsize::new(0),
983 block_first_save: AtomicBool::new(block_first_save),
984 save_started: Notify::new(),
985 release_save: Notify::new(),
986 }
987 }
988 }
989
990 #[async_trait::async_trait]
991 impl SessionAccess for TestSessionAccess {
992 async fn load_session(
993 &self,
994 _id: &str,
995 ) -> Result<Option<Session>, super::super::errors::SessionLoadError> {
996 Ok(Some(self.session.lock().unwrap().clone()))
997 }
998
999 async fn load_or_create(
1000 &self,
1001 _id: &str,
1002 _model: &str,
1003 ) -> Result<Session, super::super::errors::SessionLoadError> {
1004 Ok(self.session.lock().unwrap().clone())
1005 }
1006
1007 async fn load_merged(
1008 &self,
1009 _id: &str,
1010 ) -> Result<Option<Session>, super::super::errors::SessionLoadError> {
1011 self.loads.fetch_add(1, Ordering::SeqCst);
1012 Ok(Some(self.session.lock().unwrap().clone()))
1013 }
1014
1015 async fn save_session(
1016 &self,
1017 session: &mut Session,
1018 ) -> Result<(), super::super::errors::SessionSaveError> {
1019 if self.block_first_save.swap(false, Ordering::SeqCst) {
1020 self.save_started.notify_one();
1021 self.release_save.notified().await;
1022 }
1023 *self.session.lock().unwrap() = session.clone();
1024 self.saves.fetch_add(1, Ordering::SeqCst);
1025 Ok(())
1026 }
1027
1028 async fn save_and_cache(
1029 &self,
1030 session: &mut Session,
1031 ) -> Result<(), super::super::errors::SessionSaveError> {
1032 self.save_session(session).await
1033 }
1034 }
1035
1036 fn respond_input() -> RespondInput {
1037 RespondInput {
1038 session_id: "sess-1".to_string(),
1039 user_response: "A".to_string(),
1040 model: None,
1041 model_ref: None,
1042 provider: None,
1043 reasoning_effort: None,
1044 }
1045 }
1046
1047 fn make_pending(tool_name: &str) -> PendingQuestion {
1048 PendingQuestion {
1049 tool_call_id: "call-1".to_string(),
1050 tool_name: tool_name.to_string(),
1051 question: "Question?".to_string(),
1052 options: vec!["A".to_string(), "B".to_string()],
1053 allow_custom: false,
1054 source: bamboo_agent_core::PendingQuestionSource::PauseTool,
1055 }
1056 }
1057
1058 #[tokio::test]
1059 async fn expected_tool_call_id_accepts_the_current_question() {
1060 let repo = TestSessionAccess::with_pending();
1061
1062 submit_pending_response_checked(&repo, respond_input(), Some("call-1".to_string()))
1063 .await
1064 .expect("matching identity should submit");
1065
1066 assert_eq!(repo.saves.load(Ordering::SeqCst), 1);
1067 assert!(repo.session.lock().unwrap().pending_question.is_none());
1068 }
1069
1070 #[tokio::test]
1071 async fn stale_tool_call_id_is_rejected_without_consuming_the_question() {
1072 let repo = TestSessionAccess::with_pending();
1073
1074 let error =
1075 submit_pending_response_checked(&repo, respond_input(), Some("stale-call".to_string()))
1076 .await
1077 .expect_err("stale identity must fail");
1078
1079 assert!(matches!(
1080 error,
1081 RespondError::PendingQuestionMismatch {
1082 ref expected,
1083 ref actual,
1084 } if expected == "stale-call" && actual == "call-1"
1085 ));
1086 assert_eq!(repo.saves.load(Ordering::SeqCst), 0);
1087 assert_eq!(
1088 repo.session
1089 .lock()
1090 .unwrap()
1091 .pending_question
1092 .as_ref()
1093 .map(|question| question.tool_call_id.as_str()),
1094 Some("call-1")
1095 );
1096 }
1097
1098 #[tokio::test]
1099 async fn omitted_tool_call_guard_remains_backwards_compatible() {
1100 let repo = TestSessionAccess::with_pending();
1101
1102 submit_pending_response(&repo, respond_input())
1103 .await
1104 .expect("legacy client should remain accepted");
1105
1106 assert_eq!(repo.saves.load(Ordering::SeqCst), 1);
1107 }
1108
1109 #[tokio::test]
1110 async fn concurrent_responses_consume_a_pending_question_once() {
1111 let repo = Arc::new(TestSessionAccess::with_pending_and_blocked_save(true));
1112
1113 let first_repo = repo.clone();
1114 let first = tokio::spawn(async move {
1115 submit_pending_response_checked(
1116 first_repo.as_ref(),
1117 respond_input(),
1118 Some("call-1".to_string()),
1119 )
1120 .await
1121 });
1122 repo.save_started.notified().await;
1123
1124 let second_entered = Arc::new(Notify::new());
1125 let second_repo = repo.clone();
1126 let second_entered_task = second_entered.clone();
1127 let second = tokio::spawn(async move {
1128 second_entered_task.notify_one();
1129 submit_pending_response_checked(
1130 second_repo.as_ref(),
1131 respond_input(),
1132 Some("call-1".to_string()),
1133 )
1134 .await
1135 });
1136 second_entered.notified().await;
1137 tokio::task::yield_now().await;
1138
1139 assert_eq!(
1140 repo.loads.load(Ordering::SeqCst),
1141 1,
1142 "the second responder must wait before loading the pending question"
1143 );
1144 repo.release_save.notify_one();
1145
1146 first.await.unwrap().expect("first response should win");
1147 let error = second
1148 .await
1149 .unwrap()
1150 .expect_err("second response must observe the consumed question");
1151 assert!(matches!(error, RespondError::NoPendingQuestion));
1152 assert_eq!(repo.loads.load(Ordering::SeqCst), 2);
1153 assert_eq!(repo.saves.load(Ordering::SeqCst), 1);
1154 }
1155
1156 #[tokio::test]
1157 async fn cancelled_response_waiter_does_not_leak_diagnostics_or_the_gate() {
1158 let session_id = "cancelled-response-waiter";
1159 let owner = acquire_pending_response_guard(session_id).await;
1160 let waiter = tokio::spawn(async move { acquire_pending_response_guard(session_id).await });
1161 tokio::time::timeout(std::time::Duration::from_secs(1), async {
1162 while pending_response_waiter_count(session_id) != 1 {
1163 tokio::task::yield_now().await;
1164 }
1165 })
1166 .await
1167 .expect("waiter should reach the gate");
1168
1169 waiter.abort();
1170 let _ = waiter.await;
1171 assert_eq!(pending_response_waiter_count(session_id), 0);
1172 drop(owner);
1173
1174 tokio::time::timeout(
1175 std::time::Duration::from_secs(1),
1176 acquire_pending_response_guard(session_id),
1177 )
1178 .await
1179 .expect("cancelled waiter must not retain the gate");
1180 }
1181
1182 #[test]
1183 fn enter_plan_mode_activates_plan_mode_state() {
1184 let mut session = Session::new("sess-1", "test-model");
1185 let pending = make_pending("EnterPlanMode");
1186
1187 apply_plan_mode_transition(&mut session, &pending, "Enter plan mode", None);
1188
1189 assert!(session.agent_runtime_state.is_some());
1190 let state = session.agent_runtime_state.unwrap();
1191 assert!(state.plan_mode.is_some());
1192 let plan = state.plan_mode.unwrap();
1193 assert_eq!(plan.status, PlanModeStatus::Exploring);
1194 assert_eq!(plan.pre_permission_mode, "default");
1195 }
1196
1197 #[test]
1198 fn enter_plan_mode_does_nothing_when_not_approved() {
1199 let mut session = Session::new("sess-1", "test-model");
1200 let pending = make_pending("EnterPlanMode");
1201
1202 apply_plan_mode_transition(&mut session, &pending, "Stay in normal mode", None);
1203
1204 assert!(session.agent_runtime_state.is_none());
1205 }
1206
1207 #[test]
1208 fn plan_mode_preserves_and_restores_typed_auto_request() {
1209 let mut session = Session::new("sess-auto-plan", "test-model");
1210 session
1211 .agent_runtime_state
1212 .get_or_insert_with(|| AgentRuntimeState::new("run-1"))
1213 .set_permission_mode(SessionPermissionMode::Auto);
1214 let enter = make_pending("EnterPlanMode");
1215 let transition =
1216 apply_plan_mode_transition(&mut session, &enter, "Enter plan mode", None).unwrap();
1217 assert!(matches!(
1218 transition,
1219 PlanModeTransition::Entered {
1220 ref pre_permission_mode,
1221 ..
1222 } if pre_permission_mode == "auto"
1223 ));
1224 assert_eq!(
1225 session
1226 .agent_runtime_state
1227 .as_ref()
1228 .unwrap()
1229 .plan_mode
1230 .as_ref()
1231 .unwrap()
1232 .pre_permission_mode,
1233 "auto"
1234 );
1235
1236 let exit = make_pending("ExitPlanMode");
1237 let transition = apply_plan_mode_transition(
1238 &mut session,
1239 &exit,
1240 "Approve (Auto mode)",
1241 Some("Reviewed plan".to_string()),
1242 )
1243 .unwrap();
1244 assert!(matches!(
1245 transition,
1246 PlanModeTransition::Exited {
1247 ref restored_mode,
1248 ..
1249 } if restored_mode == "auto"
1250 ));
1251 assert_eq!(
1252 session
1253 .agent_runtime_state
1254 .as_ref()
1255 .unwrap()
1256 .effective_permission_mode(),
1257 SessionPermissionMode::Auto
1258 );
1259 }
1260
1261 #[test]
1262 fn exit_plan_mode_does_not_restore_over_a_newer_typed_mode() {
1263 let mut session = Session::new("sess-plan-patch", "test-model");
1264 let state = session
1265 .agent_runtime_state
1266 .get_or_insert_with(|| AgentRuntimeState::new("run-1"));
1267 state.set_permission_mode(SessionPermissionMode::Auto);
1268 state.plan_mode = Some(PlanModeState {
1269 entered_at: Utc::now(),
1270 pre_permission_mode: "auto".to_string(),
1271 plan_file_path: None,
1272 status: PlanModeStatus::AwaitingApproval,
1273 });
1274 state.set_permission_mode(SessionPermissionMode::Bypass);
1276
1277 let transition = apply_plan_mode_transition(
1278 &mut session,
1279 &make_pending("ExitPlanMode"),
1280 "Approve (Default mode)",
1281 None,
1282 )
1283 .unwrap();
1284
1285 assert!(matches!(
1286 transition,
1287 PlanModeTransition::Exited {
1288 ref restored_mode,
1289 ..
1290 } if restored_mode == "bypass"
1291 ));
1292 let state = session.agent_runtime_state.unwrap();
1293 assert!(state.plan_mode.is_none());
1294 assert_eq!(
1295 state.effective_permission_mode(),
1296 SessionPermissionMode::Bypass
1297 );
1298 }
1299
1300 #[test]
1301 fn exit_plan_mode_clears_plan_mode_state() {
1302 let mut session = Session::new("sess-1", "test-model");
1303 session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
1304 session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
1305 entered_at: Utc::now(),
1306 pre_permission_mode: "default".to_string(),
1307 plan_file_path: None,
1308 status: PlanModeStatus::AwaitingApproval,
1309 });
1310 let pending = make_pending("ExitPlanMode");
1311
1312 apply_plan_mode_transition(
1313 &mut session,
1314 &pending,
1315 "Approve (Default mode)",
1316 Some("Reviewed plan".to_string()),
1317 );
1318
1319 assert!(session.agent_runtime_state.unwrap().plan_mode.is_none());
1320 }
1321
1322 #[test]
1323 fn exit_plan_mode_keeps_plan_mode_when_not_approved() {
1324 let mut session = Session::new("sess-1", "test-model");
1325 session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
1326 session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
1327 entered_at: Utc::now(),
1328 pre_permission_mode: "default".to_string(),
1329 plan_file_path: None,
1330 status: PlanModeStatus::AwaitingApproval,
1331 });
1332 let pending = make_pending("ExitPlanMode");
1333
1334 apply_plan_mode_transition(&mut session, &pending, "Stay in plan mode", None);
1335
1336 assert!(session.agent_runtime_state.unwrap().plan_mode.is_some());
1337 }
1338
1339 #[test]
1340 fn exit_plan_mode_ignores_other_tools() {
1341 let mut session = Session::new("sess-1", "test-model");
1342 let pending = make_pending("ConclusionWithOptions");
1343
1344 apply_plan_mode_transition(&mut session, &pending, "Approve", None);
1345
1346 assert!(session.agent_runtime_state.is_none());
1347 }
1348
1349 #[test]
1350 fn is_exit_plan_mode_approved_detects_approval() {
1351 assert!(is_exit_plan_mode_approved("Approve (Default mode)"));
1352 assert!(is_exit_plan_mode_approved("Approve (Accept edits mode)"));
1353 assert!(!is_exit_plan_mode_approved("Stay in plan mode"));
1354 assert!(!is_exit_plan_mode_approved("Edit plan first"));
1355 }
1356
1357 #[test]
1358 fn extract_exit_plan_from_tool_result_message_reads_plan_payload() {
1359 let mut session = Session::new("sess-1", "test-model");
1360 let mut tool_message = bamboo_agent_core::Message::tool_result(
1361 "call-1",
1362 serde_json::json!({
1363 "plan": "# Plan\n\n1. Step"
1364 })
1365 .to_string(),
1366 );
1367 tool_message.tool_success = Some(true);
1368 session.add_message(tool_message);
1369
1370 let plan = extract_exit_plan_from_tool_result_message(&session, "call-1");
1371 assert_eq!(plan.as_deref(), Some("# Plan\n\n1. Step"));
1372 }
1373
1374 #[test]
1375 fn selected_permission_preserves_typed_request_and_receipt_in_non_visible_metadata() {
1376 let mut session = Session::new("sess-1", "test-model");
1377 session.add_message(bamboo_agent_core::Message::tool_result(
1378 "permission-1",
1379 serde_json::json!({
1380 "status": "awaiting_permission_approval",
1381 "permission_request": {
1382 "request_id": "permission-1",
1383 "request_generation": "generation-1",
1384 "session_id": "sess-1",
1385 "allowed_decisions": ["allow_once", "deny_once"]
1386 }
1387 })
1388 .to_string(),
1389 ));
1390 session
1391 .messages
1392 .last_mut()
1393 .expect("permission tool result")
1394 .metadata = Some(serde_json::json!("legacy-metadata"));
1395
1396 assert!(update_or_append_tool_result_message(
1397 &mut session,
1398 "permission-1",
1399 "Approve",
1400 ResponseSource::Human,
1401 ));
1402 let receipt = PermissionDecisionReceipt {
1403 session_id: "sess-1".to_string(),
1404 decision: bamboo_tools::permission::PermissionDecision {
1405 request_id: "permission-1".to_string(),
1406 request_generation: "generation-1".to_string(),
1407 decision: bamboo_tools::permission::PermissionDecisionKind::AllowOnce,
1408 matcher_id: None,
1409 expected_policy_revision: Some(4),
1410 confirm_global: false,
1411 },
1412 decided_at: Utc::now(),
1413 };
1414 assert!(persist_permission_decision_receipt(
1415 &mut session,
1416 "permission-1",
1417 &receipt
1418 ));
1419
1420 let message = session
1421 .messages
1422 .iter()
1423 .find(|message| message.tool_call_id.as_deref() == Some("permission-1"))
1424 .expect("permission tool result");
1425 assert_eq!(message.content, "Selected response: Approve");
1426 assert_eq!(
1427 message
1428 .metadata
1429 .as_ref()
1430 .and_then(|metadata| metadata.get("permission_request"))
1431 .and_then(|request| request.get("request_id"))
1432 .and_then(serde_json::Value::as_str),
1433 Some("permission-1")
1434 );
1435 assert_eq!(
1436 message
1437 .metadata
1438 .as_ref()
1439 .and_then(|metadata| metadata.get("previous_metadata")),
1440 Some(&serde_json::json!("legacy-metadata"))
1441 );
1442 assert_eq!(
1443 message
1444 .metadata
1445 .as_ref()
1446 .and_then(|metadata| metadata.get("permission_decision_receipt"))
1447 .cloned()
1448 .and_then(|receipt| {
1449 serde_json::from_value::<PermissionDecisionReceipt>(receipt).ok()
1450 }),
1451 Some(receipt)
1452 );
1453 }
1454
1455 #[test]
1456 fn direct_parent_pending_cannot_be_answered_by_human_respond_text() {
1457 let mut session = Session::new("direct-parent-child", "test-model");
1458 session.set_pending_question_with_source(
1459 "question-call".into(),
1460 "AskUserQuestion".into(),
1461 "Which option?".into(),
1462 vec!["A".into(), "B".into()],
1463 true,
1464 bamboo_agent_core::PendingQuestionSource::DirectParent,
1465 );
1466 let input = RespondInput {
1467 session_id: session.id.clone(),
1468 user_response: "A".into(),
1469 model: None,
1470 model_ref: None,
1471 provider: None,
1472 reasoning_effort: None,
1473 };
1474 assert!(matches!(
1475 apply_pending_response(&mut session, &input, Some("question-call"), ResponseSource::Human, None),
1476 Err(RespondError::InvalidResponse(message)) if message.contains("Direct-parent")
1477 ));
1478 assert!(session.pending_question.is_some());
1479 }
1480
1481 #[test]
1482 fn reused_tool_call_id_requires_current_permission_generation() {
1483 let mut session = Session::new("sess-1", "test-model");
1484 for generation in ["generation-old", "generation-current"] {
1485 session.add_message(bamboo_agent_core::Message::tool_result(
1486 "permission-reused",
1487 serde_json::json!({
1488 "status": "awaiting_permission_approval",
1489 "permission_type": "execute_command",
1490 "resource": format!("resource-{generation}"),
1491 "permission_request": {
1492 "request_id": "permission-reused",
1493 "request_generation": generation,
1494 "session_id": "sess-1",
1495 "allowed_decisions": ["allow_once", "deny_once"]
1496 }
1497 })
1498 .to_string(),
1499 ));
1500 }
1501 session.set_pending_question_with_source(
1502 "permission-reused".to_string(),
1503 "Bash".to_string(),
1504 "Allow the current operation?".to_string(),
1505 vec!["Approve".to_string(), "Deny".to_string()],
1506 false,
1507 bamboo_agent_core::PendingQuestionSource::PauseTool,
1508 );
1509 let input = RespondInput {
1510 session_id: "sess-1".to_string(),
1511 user_response: "Approve".to_string(),
1512 model: None,
1513 model_ref: None,
1514 provider: None,
1515 reasoning_effort: None,
1516 };
1517 let receipt = |generation: &str| PermissionDecisionReceipt {
1518 session_id: "sess-1".to_string(),
1519 decision: bamboo_tools::permission::PermissionDecision {
1520 request_id: "permission-reused".to_string(),
1521 request_generation: generation.to_string(),
1522 decision: bamboo_tools::permission::PermissionDecisionKind::AllowOnce,
1523 matcher_id: None,
1524 expected_policy_revision: None,
1525 confirm_global: false,
1526 },
1527 decided_at: Utc::now(),
1528 };
1529
1530 let stale = receipt("generation-old");
1531 assert!(matches!(
1532 apply_pending_response(
1533 &mut session,
1534 &input,
1535 Some("permission-reused"),
1536 ResponseSource::Human,
1537 Some(&stale),
1538 ),
1539 Err(RespondError::InvalidResponse(message))
1540 if message.contains("generation")
1541 ));
1542 assert!(session.pending_question.is_some());
1543
1544 let current = receipt("generation-current");
1545 apply_pending_response(
1546 &mut session,
1547 &input,
1548 Some("permission-reused"),
1549 ResponseSource::Human,
1550 Some(¤t),
1551 )
1552 .expect("current generation resolves the parked operation");
1553 assert!(session.pending_question.is_none());
1554 assert_eq!(
1555 session
1556 .metadata
1557 .get(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY)
1558 .map(String::as_str),
1559 Some("generation-current")
1560 );
1561 assert!(session.messages[0].content.contains("generation-old"));
1562 assert_eq!(session.messages[1].content, "Selected response: Approve");
1563 assert_eq!(
1564 session.messages[1]
1565 .metadata
1566 .as_ref()
1567 .and_then(|metadata| metadata.get("permission_decision_receipt"))
1568 .and_then(|receipt| receipt.get("decision"))
1569 .and_then(|decision| decision.get("request_generation"))
1570 .and_then(serde_json::Value::as_str),
1571 Some("generation-current")
1572 );
1573 }
1574
1575 #[test]
1576 fn typed_permission_receipt_controls_replay_independent_of_display_options() {
1577 let session = || {
1578 let mut session = Session::new("sess-typed", "test-model");
1579 session.add_message(bamboo_agent_core::Message::tool_result(
1580 "permission-localized",
1581 serde_json::json!({
1582 "status": "awaiting_permission_approval",
1583 "permission_type": "execute_command",
1584 "resource": "cargo test --workspace",
1585 "permission_request": {
1586 "request_id": "permission-localized",
1587 "request_generation": "generation-localized",
1588 "session_id": "sess-typed",
1589 "allowed_decisions": ["allow_once", "deny_once"]
1590 }
1591 })
1592 .to_string(),
1593 ));
1594 session.set_pending_question_with_source(
1595 "permission-localized".to_string(),
1596 "Bash".to_string(),
1597 "允许执行吗?".to_string(),
1598 vec!["允许".to_string(), "拒绝".to_string()],
1599 false,
1600 bamboo_agent_core::PendingQuestionSource::PauseTool,
1601 );
1602 session
1603 };
1604 let receipt = |decision| PermissionDecisionReceipt {
1605 session_id: "sess-typed".to_string(),
1606 decision: bamboo_tools::permission::PermissionDecision {
1607 request_id: "permission-localized".to_string(),
1608 request_generation: "generation-localized".to_string(),
1609 decision,
1610 matcher_id: None,
1611 expected_policy_revision: None,
1612 confirm_global: false,
1613 },
1614 decided_at: Utc::now(),
1615 };
1616
1617 let mut allowed = session();
1618 let allow_input = RespondInput {
1619 session_id: "sess-typed".to_string(),
1620 user_response: "已由结构化决定允许".to_string(),
1621 model: None,
1622 model_ref: None,
1623 provider: None,
1624 reasoning_effort: None,
1625 };
1626 apply_pending_response(
1627 &mut allowed,
1628 &allow_input,
1629 Some("permission-localized"),
1630 ResponseSource::Human,
1631 Some(&receipt(PermissionDecisionKind::AllowOnce)),
1632 )
1633 .expect("typed allow must not depend on localized display options");
1634 assert_eq!(
1635 allowed
1636 .metadata
1637 .get(PERMISSION_REEXECUTE_METADATA_KEY)
1638 .map(String::as_str),
1639 Some("permission-localized")
1640 );
1641 assert_eq!(
1642 allowed
1643 .metadata
1644 .get(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY)
1645 .map(String::as_str),
1646 Some("generation-localized")
1647 );
1648
1649 let mut denied = session();
1650 denied.metadata.insert(
1651 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1652 "stale-call".to_string(),
1653 );
1654 denied.metadata.insert(
1655 PERMISSION_REEXECUTE_GENERATION_METADATA_KEY.to_string(),
1656 "stale-generation".to_string(),
1657 );
1658 let deny_input = RespondInput {
1659 session_id: "sess-typed".to_string(),
1660 user_response: "Approve".to_string(),
1662 model: None,
1663 model_ref: None,
1664 provider: None,
1665 reasoning_effort: None,
1666 };
1667 apply_pending_response(
1668 &mut denied,
1669 &deny_input,
1670 Some("permission-localized"),
1671 ResponseSource::Human,
1672 Some(&receipt(PermissionDecisionKind::DenyOnce)),
1673 )
1674 .expect("typed deny must not depend on display text");
1675 assert!(!denied
1676 .metadata
1677 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
1678 assert!(!denied
1679 .metadata
1680 .contains_key(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY));
1681
1682 let mut legacy = session();
1683 assert!(matches!(
1684 apply_pending_response(
1685 &mut legacy,
1686 &allow_input,
1687 Some("permission-localized"),
1688 ResponseSource::Human,
1689 None,
1690 ),
1691 Err(RespondError::InvalidResponse(_))
1692 ));
1693 assert!(legacy.pending_question.is_some());
1694 }
1695}
1696
1697#[cfg(test)]
1698mod no_progress_tests;
1699
1700#[cfg(test)]
1701mod receipt_persistence_tests {
1702 use super::*;
1703 use crate::session_app::approval_replay::{
1704 find_permission_replay_target, restore_permission_replay_authorization,
1705 };
1706 use crate::{read_cached_session, SessionCache, SessionRepository};
1707 use bamboo_agent_core::storage::Storage;
1708 use bamboo_agent_core::tools::{FunctionCall, ToolCall};
1709 use bamboo_storage::{LockedSessionStore, SessionStoreV2};
1710 use bamboo_tools::permission::{
1711 PermissionConfig, PermissionDecision, PermissionMode, PermissionReasonCode,
1712 PermissionRequest, RiskLevel,
1713 };
1714
1715 fn pending_permission(
1716 session_id: &str,
1717 ) -> (Session, PermissionRequest, PermissionDecisionReceipt) {
1718 let mut session = Session::new(session_id, "test-model");
1719 session.agent_runtime_state = Some(AgentRuntimeState::new("receipt-test-run"));
1720 let request = |generation: &str, resource: &str| PermissionRequest {
1721 request_id: "permission-reused".into(),
1722 request_generation: generation.into(),
1723 session_id: session_id.into(),
1724 workspace_path: None,
1725 tool_name: "Bash".into(),
1726 permission_type: PermissionType::ExecuteCommand,
1727 resource: resource.into(),
1728 operation_summary: format!("execute {resource}"),
1729 risk_level: RiskLevel::High,
1730 reason_code: PermissionReasonCode::RiskThreshold,
1731 effective_mode: PermissionMode::Default,
1732 bypass_requested: false,
1733 auto_approve_requested: false,
1734 policy_revision: 4,
1735 matched_rule: None,
1736 allowed_decisions: vec![
1737 PermissionDecisionKind::AllowOnce,
1738 PermissionDecisionKind::DenyOnce,
1739 ],
1740 suggested_matchers: vec![],
1741 };
1742 let current = request("generation-current", "current-command");
1743 for (name, request) in [
1744 ("old", request("generation-old", "old-command")),
1745 ("current", current.clone()),
1746 ] {
1747 session.add_message(Message::assistant(
1748 "",
1749 Some(vec![ToolCall {
1750 id: request.request_id.clone(),
1751 tool_type: "function".into(),
1752 function: FunctionCall {
1753 name: request.tool_name.clone(),
1754 arguments: serde_json::json!({"command": request.resource}).to_string(),
1755 },
1756 }]),
1757 ));
1758 let mut result = Message::tool_result_with_status(
1759 &request.request_id,
1760 serde_json::json!({
1761 "status": "awaiting_permission_approval",
1762 "permission_type": request.permission_type,
1763 "resource": request.resource,
1764 "permission_request": request,
1765 })
1766 .to_string(),
1767 false,
1768 );
1769 result.id = format!("result-{name}");
1770 session.add_message(result);
1771 }
1772 session.set_pending_question_with_source(
1773 current.request_id.clone(),
1774 current.tool_name.clone(),
1775 "允许本次操作?".into(),
1776 vec!["允许".into(), "拒绝".into()],
1777 false,
1778 bamboo_agent_core::PendingQuestionSource::PauseTool,
1779 );
1780 session.metadata.insert(
1781 "runtime.suspend_reason".into(),
1782 "awaiting_clarification".into(),
1783 );
1784 let receipt = PermissionDecisionReceipt {
1785 session_id: session_id.into(),
1786 decision: PermissionDecision {
1787 request_id: current.request_id.clone(),
1788 request_generation: current.request_generation.clone(),
1789 decision: PermissionDecisionKind::AllowOnce,
1790 matcher_id: None,
1791 expected_policy_revision: Some(current.policy_revision),
1792 confirm_global: false,
1793 },
1794 decided_at: Utc::now(),
1795 };
1796 (session, current, receipt)
1797 }
1798
1799 async fn repository(
1800 session: &mut Session,
1801 ) -> (tempfile::TempDir, Arc<SessionStoreV2>, SessionRepository) {
1802 let directory = tempfile::tempdir().unwrap();
1803 let store = Arc::new(
1804 SessionStoreV2::new(directory.path().to_path_buf())
1805 .await
1806 .unwrap(),
1807 );
1808 let storage: Arc<dyn Storage> = store.clone();
1809 let repo = SessionRepository::new(
1810 SessionCache::default(),
1811 storage.clone(),
1812 Arc::new(LockedSessionStore::new(storage)),
1813 );
1814 repo.save(session).await.unwrap();
1815 (directory, store, repo)
1816 }
1817
1818 fn input(session_id: &str) -> RespondInput {
1819 RespondInput {
1820 session_id: session_id.into(),
1821 user_response: "允许".into(),
1822 model: None,
1823 model_ref: None,
1824 provider: None,
1825 reasoning_effort: None,
1826 }
1827 }
1828
1829 #[tokio::test]
1830 async fn typed_response_round_trip_restores_only_the_exact_allow_once() {
1831 eprintln!("debug_assertions={}", cfg!(debug_assertions));
1832 let (mut session, request, receipt) = pending_permission("receipt-round-trip");
1833 let (_directory, store, repo) = repository(&mut session).await;
1834 let old_occurrence = serde_json::to_value(&session.messages[..2]).unwrap();
1835 let guard = acquire_pending_response_guard(&session.id).await;
1836 let (accepted, _, _, _) = submit_pending_permission_response_checked_guarded(
1837 &repo,
1838 input(&session.id),
1839 Some(request.request_id.clone()),
1840 receipt.clone(),
1841 &guard,
1842 )
1843 .await
1844 .expect("public typed response accepts the current occurrence");
1845 let durable = store.load_session(&session.id).await.unwrap().unwrap();
1846 let restarted: Session =
1847 serde_json::from_slice(&serde_json::to_vec(&durable).unwrap()).unwrap();
1848 assert!(accepted.pending_question.is_none() && restarted.pending_question.is_none());
1849 assert_eq!(
1850 serde_json::to_value(&restarted.messages[..2]).unwrap(),
1851 old_occurrence
1852 );
1853 let message = restarted
1854 .messages
1855 .iter()
1856 .find(|message| message.id == "result-current")
1857 .unwrap();
1858 assert_eq!(
1859 message.tool_call_id.as_deref(),
1860 Some(request.request_id.as_str())
1861 );
1862 assert_eq!(message.content, "Selected response: 允许");
1863 assert_eq!(message.tool_success, Some(true));
1864 let metadata = message
1865 .metadata
1866 .as_ref()
1867 .expect("preserved typed request metadata");
1868 assert_eq!(
1869 metadata.get("permission_request"),
1870 Some(&serde_json::to_value(&request).unwrap())
1871 );
1872 assert_eq!(
1873 metadata.get("permission_decision_receipt"),
1874 Some(&serde_json::to_value(&receipt).unwrap()),
1875 "the public response must persist the complete receipt, including decided_at"
1876 );
1877 assert_eq!(
1878 restarted.metadata.get(PERMISSION_REEXECUTE_METADATA_KEY),
1879 Some(&request.request_id)
1880 );
1881 assert_eq!(
1882 restarted
1883 .metadata
1884 .get(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY),
1885 Some(&request.request_generation)
1886 );
1887
1888 let target = find_permission_replay_target(
1889 &restarted,
1890 &request.request_id,
1891 Some(&request.request_generation),
1892 )
1893 .expect("replay resolves the exact current occurrence after reload");
1894 assert_eq!(
1895 target.request_generation(),
1896 Some(request.request_generation.as_str())
1897 );
1898 assert_eq!(
1899 target.tool_call().function.arguments,
1900 serde_json::json!({"command":"current-command"}).to_string()
1901 );
1902 let config = PermissionConfig::new();
1903 restore_permission_replay_authorization(&config, &restarted, &target, "Bash").unwrap();
1904 for (session_id, call_id, generation, resource) in [
1907 (
1908 restarted.id.as_str(),
1909 request.request_id.as_str(),
1910 "generation-old",
1911 request.resource.as_str(),
1912 ),
1913 (
1914 restarted.id.as_str(),
1915 request.request_id.as_str(),
1916 request.request_generation.as_str(),
1917 "other-command",
1918 ),
1919 (
1920 "other-session",
1921 request.request_id.as_str(),
1922 request.request_generation.as_str(),
1923 request.resource.as_str(),
1924 ),
1925 ] {
1926 assert!(!config.consume_once_for_generation(
1927 session_id,
1928 call_id,
1929 generation,
1930 request.permission_type,
1931 resource
1932 ));
1933 }
1934 assert!(config.consume_once_for_generation(
1935 &restarted.id,
1936 &request.request_id,
1937 &request.request_generation,
1938 request.permission_type,
1939 &request.resource
1940 ));
1941 assert!(!config.consume_once_for_generation(
1942 &restarted.id,
1943 &request.request_id,
1944 &request.request_generation,
1945 request.permission_type,
1946 &request.resource
1947 ));
1948 }
1949
1950 #[tokio::test]
1951 async fn typed_deny_round_trip_retains_receipt_without_grants_or_replay() {
1952 eprintln!("debug_assertions={}", cfg!(debug_assertions));
1953 let (mut session, request, mut receipt) = pending_permission("receipt-deny");
1954 receipt.decision.decision = PermissionDecisionKind::DenyOnce;
1955 session.metadata.insert(
1956 PERMISSION_REEXECUTE_METADATA_KEY.into(),
1957 "stale-call".into(),
1958 );
1959 session.metadata.insert(
1960 PERMISSION_REEXECUTE_GENERATION_METADATA_KEY.into(),
1961 "stale-generation".into(),
1962 );
1963 let (_directory, store, repo) = repository(&mut session).await;
1964 let old_occurrence = serde_json::to_value(&session.messages[..2]).unwrap();
1965 let mut response = input(&session.id);
1966 response.user_response = "Approve".into();
1967 let guard = acquire_pending_response_guard(&session.id).await;
1968 let (_, display_response, _, grants) = submit_pending_permission_response_checked_guarded(
1969 &repo,
1970 response,
1971 Some(request.request_id.clone()),
1972 receipt.clone(),
1973 &guard,
1974 )
1975 .await
1976 .expect("typed deny accepts display text without granting permission");
1977 assert_eq!(display_response, "Approve");
1978 assert!(grants.is_empty());
1979 let durable = store.load_session(&session.id).await.unwrap().unwrap();
1980 let restarted: Session =
1981 serde_json::from_slice(&serde_json::to_vec(&durable).unwrap()).unwrap();
1982 assert!(restarted.pending_question.is_none());
1983 assert_eq!(
1984 serde_json::to_value(&restarted.messages[..2]).unwrap(),
1985 old_occurrence
1986 );
1987 let message = restarted.messages.last().unwrap();
1988 assert_eq!(message.id, "result-current");
1989 assert_eq!(message.content, "Selected response: Approve");
1990 let metadata = message.metadata.as_ref().unwrap();
1991 assert_eq!(
1992 metadata.get("permission_request"),
1993 Some(&serde_json::to_value(&request).unwrap())
1994 );
1995 assert_eq!(
1996 metadata.get("permission_decision_receipt"),
1997 Some(&serde_json::to_value(&receipt).unwrap())
1998 );
1999 assert!(!restarted
2000 .metadata
2001 .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
2002 assert!(!restarted
2003 .metadata
2004 .contains_key(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY));
2005 }
2006
2007 #[tokio::test]
2008 async fn failed_typed_receipt_does_not_publish_a_partial_response() {
2009 eprintln!("debug_assertions={}", cfg!(debug_assertions));
2010 let (mut session, request, receipt) = pending_permission("receipt-conflict");
2011 let current = session.messages.last_mut().unwrap();
2012 current.metadata = Some(serde_json::json!({"permission_request": request}));
2013 let mut payload: serde_json::Value = serde_json::from_str(¤t.content).unwrap();
2014 payload["permission_request"]["request_generation"] =
2015 "generation-conflicting-payload".into();
2016 current.content = payload.to_string();
2017 let (_directory, store, repo) = repository(&mut session).await;
2018 let durable_before = store.load_session(&session.id).await.unwrap().unwrap();
2019 assert_eq!(
2023 permission_request_generation(durable_before.messages.last().unwrap()).as_deref(),
2024 Some(receipt.decision.request_generation.as_str())
2025 );
2026 let cache_before =
2027 serde_json::to_value(read_cached_session(repo.cache(), &session.id).unwrap()).unwrap();
2028 let durable_before = serde_json::to_value(durable_before).unwrap();
2029 let guard = acquire_pending_response_guard(&session.id).await;
2030 let result = submit_pending_permission_response_checked_guarded(
2031 &repo,
2032 input(&session.id),
2033 Some(request.request_id),
2034 receipt,
2035 &guard,
2036 )
2037 .await;
2038 assert!(
2039 matches!(result, Err(RespondError::InvalidResponse(ref message)) if message.contains("receipt")),
2040 "failed receipt persistence must be an explicit response error: {result:?}"
2041 );
2042 assert_eq!(
2043 serde_json::to_value(store.load_session(&session.id).await.unwrap().unwrap()).unwrap(),
2044 durable_before,
2045 "pending question, result and all durable markers stay unchanged"
2046 );
2047 assert_eq!(
2048 serde_json::to_value(read_cached_session(repo.cache(), &session.id).unwrap()).unwrap(),
2049 cache_before,
2050 "a rejected response must not publish a cache snapshot"
2051 );
2052 }
2053}