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