Skip to main content

bamboo_engine/session_app/
respond.rs

1//! Respond use case: submit a user response to a pending question.
2
3use 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
33/// Process-local serialization for consuming one pending question. The
34/// persistence layer serializes individual loads and saves, but releasing its
35/// lock between those operations would let two responders both observe and
36/// consume the same question. Every response entrypoint reaches this use case,
37/// so holding this gate across load -> validate -> save makes one consumer win
38/// and forces later callers to reload the already-consumed state.
39struct 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
87/// Process-local ownership for one session's complete response transaction.
88/// High-level HTTP, Connect, and Gold paths hold this from authoritative
89/// preflight through successor dispatch, so a stale duplicate cannot reserve
90/// and later cancel a phantom runner after the real successor has completed.
91pub 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
109/// Acquire the shared response single-flight used by every response source.
110pub 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/// Number of callers currently waiting to enter a session's response gate.
124/// Exposed for deterministic concurrency tests and lightweight diagnostics.
125#[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
134/// Reload the exact snapshot a response CAS would mutate while holding the
135/// shared response single-flight. This must happen before successor
136/// reservation so an already-consumed/stale response allocates no runner.
137pub 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
146/// Session-metadata key marking a tool call that was approved through a permission
147/// prompt and must be RE-EXECUTED on resume. The gated tool never actually ran
148/// (the permission gate intercepted it before execution), so on approval the
149/// server resume adapter re-runs it and writes the real output back — instead of
150/// leaving the model to infer/fabricate it. Value = the tool_call_id.
151pub 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
177/// Submit a pending response: load session, validate, update messages,
178/// apply plan mode transitions, persist, and return the updated session.
179///
180/// The caller (handler) is responsible for auto-resume triggering.
181pub 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
196/// Submit a response guarded by the exact pending tool-call identity displayed
197/// to a typed client. The separate parameter keeps the established public
198/// [`RespondInput`] struct source-compatible for SDK/in-process callers.
199pub 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
221/// Human-response variant for callers that already hold the shared response
222/// transaction guard across preflight, reservation, CAS, and dispatch.
223pub 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
247/// Typed-permission variant that persists the exact decision receipt in the
248/// same durable session mutation that consumes the pending question.
249pub 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
316/// Guarded form for high-level response + resume transactions. The caller
317/// must retain `guard` until the exact successor reservation has been handed
318/// to its detached execution owner.
319pub 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    // Typed permission control flow comes exclusively from the structured
473    // receipt. Display strings and localized options remain transcript-only.
474    // Legacy clarifications still validate their selected display option.
475    if permission_receipt.is_none() {
476        if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
477            // Put the pending question back when validation fails.
478            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    // Permission grants implied by approving a permission prompt. Read from the
503    // (still-unmodified) synthesized tool-result payload, BEFORE it is overwritten
504    // by the user's selection below.
505    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        // Approved a permission prompt: mark the gated tool call for re-execution
527        // on resume so the operation actually runs (real output) rather than the
528        // model inferring it. Consumed by the server resume adapter.
529        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        // A typed deny cannot inherit replay markers from an older occurrence.
545        session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
546        session
547            .metadata
548            .remove(PERMISSION_REEXECUTE_GENERATION_METADATA_KEY);
549    }
550
551    // ---- Update or append tool result message ----
552    let found = if no_progress_question {
553        // Runtime questions have no ToolCall. Keep every real tool result and
554        // append the Human's direction as an ordinary User turn.
555        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    // ---- Plan mode state transitions ----
590    let plan_mode_transition =
591        apply_plan_mode_transition(session, &pending, &input.user_response, reviewed_plan);
592
593    // ---- Clear pending question and set resume marker ----
594    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    // ---- Merge model/reasoning from request ----
612    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
670/// Apply plan mode state transitions based on the pending question tool and user response.
671fn 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                // The typed requested mode remains live while Plan is active and
716                // may have been changed by a newer PATCH. Exiting Plan clears
717                // only the overlay; the old pre-mode is event history, never a
718                // write authority that may roll back the newer request.
719                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
735/// Check if the user response approves exiting plan mode.
736fn 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
741// ---- Internal helpers ----
742
743pub 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            // Retain invalid input for fail-closed inspection; replacing the
769            // payload must not disguise forged authority as absent legacy data.
770            if result_payload_has_supervisor_authority(message) {
771                return false;
772            }
773            // Preserve the server-issued typed permission contract outside the
774            // model-visible content before replacing the synthetic waiting
775            // payload with the selected answer. This lets an exact durable
776            // decision receipt be reconstructed after a daemon restart without
777            // trusting display strings or replaying an already-consumed run.
778            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
893/// Detect whether the user response approves a pending permission request.
894///
895/// Permission prompts (synthesized by the permission gate, and the
896/// `request_permissions` tool) offer exactly `["Approve", "Deny"]`.
897fn is_permission_approval(user_response: &str) -> bool {
898    user_response.trim().eq_ignore_ascii_case("approve")
899}
900
901/// Extract the permission grants implied by an approved permission prompt.
902///
903/// Reads the pending tool-result message (still the synthesized
904/// `awaiting_permission_approval` payload, before it is overwritten by the
905/// user's selection) and returns the `(PermissionType, resource)` pairs the
906/// caller should grant for the session. Handles both the single-gated-tool shape
907/// (top-level `permission_type` + `resource`) and the `request_permissions` shape
908/// (a `permissions` array).
909fn 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        // Simulate a newer PATCH while the Plan overlay is still active.
1275        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(&current),
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            // Deliberately approval-looking: the receipt enum must win.
1661            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        // Try mismatches before consuming the valid grant, so these assertions
1905        // cannot pass merely because the one-shot grant was already exhausted.
1906        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(&current.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        // The real preflight reads metadata A, while display migration will
2020        // preserve payload B. This reaches a failed receipt write without a
2021        // test-only writer hook or a new storage failure protocol.
2022        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}