Skip to main content

bamboo_engine/session_app/
respond.rs

1//! Respond use case: submit a user response to a pending question.
2
3use bamboo_agent_core::{PendingQuestion, Session};
4use bamboo_domain::session::runtime_state::{AgentRuntimeState, PlanModeState, PlanModeStatus};
5use bamboo_domain::SessionPermissionMode;
6use bamboo_tools::permission::PermissionType;
7use chrono::Utc;
8
9use super::errors::RespondError;
10use super::execute::mark_startup_handoff;
11use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
12use super::repository::SessionAccess;
13use super::types::RespondInput;
14
15const CLARIFICATION_RESUME_PENDING_KEY: &str = "clarification_resume_pending";
16const CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY: &str = "conclusion_with_options_resume_pending";
17
18/// Session-metadata key marking a tool call that was approved through a permission
19/// prompt and must be RE-EXECUTED on resume. The gated tool never actually ran
20/// (the permission gate intercepted it before execution), so on approval the
21/// server resume adapter re-runs it and writes the real output back — instead of
22/// leaving the model to infer/fabricate it. Value = the tool_call_id.
23pub const PERMISSION_REEXECUTE_METADATA_KEY: &str = "permission.reexecute_tool_call_id";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ResponseSource {
27    Human,
28    Gold,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PlanModeTransition {
33    Entered {
34        reason: Option<String>,
35        pre_permission_mode: String,
36        entered_at: chrono::DateTime<chrono::Utc>,
37        status: PlanModeStatus,
38        plan_file_path: Option<String>,
39    },
40    Exited {
41        approved: bool,
42        restored_mode: String,
43        plan: Option<String>,
44    },
45}
46
47/// Submit a pending response: load session, validate, update messages,
48/// apply plan mode transitions, persist, and return the updated session.
49///
50/// The caller (handler) is responsible for auto-resume triggering.
51pub async fn submit_pending_response(
52    repo: &dyn SessionAccess,
53    input: RespondInput,
54) -> Result<
55    (
56        Session,
57        String,
58        Option<PlanModeTransition>,
59        Vec<(PermissionType, String)>,
60    ),
61    RespondError,
62> {
63    submit_pending_response_with_source(repo, input, ResponseSource::Human).await
64}
65
66pub async fn submit_pending_response_with_source(
67    repo: &dyn SessionAccess,
68    input: RespondInput,
69    response_source: ResponseSource,
70) -> Result<
71    (
72        Session,
73        String,
74        Option<PlanModeTransition>,
75        Vec<(PermissionType, String)>,
76    ),
77    RespondError,
78> {
79    // ---- Load session (merged for respond to pick up in-memory pending question) ----
80    let mut session = repo
81        .load_merged(&input.session_id)
82        .await?
83        .ok_or_else(|| RespondError::NotFound(input.session_id.clone()))?;
84
85    // ---- Take pending question ----
86    let pending = session
87        .pending_question
88        .take()
89        .ok_or(RespondError::NoPendingQuestion)?;
90
91    // ---- Validate response ----
92    if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
93        // Put the pending question back when validation fails.
94        session.pending_question = Some(pending);
95        return Err(RespondError::InvalidResponse(error_message));
96    }
97
98    let tool_call_id = pending.tool_call_id.clone();
99    tracing::debug!(
100        "[{}] Looking for tool result message with tool_call_id: {}",
101        input.session_id,
102        tool_call_id
103    );
104
105    let reviewed_plan = extract_exit_plan_from_tool_result_message(&session, &tool_call_id);
106
107    // Permission grants implied by approving a permission prompt. Read from the
108    // (still-unmodified) synthesized tool-result payload, BEFORE it is overwritten
109    // by the user's selection below.
110    let permission_grants = if is_permission_approval(&input.user_response) {
111        extract_permission_grants_from_tool_result_message(&session, &tool_call_id)
112    } else {
113        Vec::new()
114    };
115    if !permission_grants.is_empty() {
116        // Approved a permission prompt: mark the gated tool call for re-execution
117        // on resume so the operation actually runs (real output) rather than the
118        // model inferring it. Consumed by the server resume adapter.
119        session.metadata.insert(
120            PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
121            tool_call_id.clone(),
122        );
123    }
124
125    // ---- Update or append tool result message ----
126    let found = update_or_append_tool_result_message(
127        &mut session,
128        &tool_call_id,
129        &input.user_response,
130        response_source,
131    );
132    if found {
133        tracing::info!(
134            "[{}] Updated existing tool result message",
135            input.session_id
136        );
137    } else {
138        tracing::warn!(
139            "[{}] Tool result message not found for tool_call_id: {}, added fallback message",
140            input.session_id,
141            tool_call_id
142        );
143    }
144
145    // ---- Plan mode state transitions ----
146    let plan_mode_transition =
147        apply_plan_mode_transition(&mut session, &pending, &input.user_response, reviewed_plan);
148
149    // ---- Clear pending question and set resume marker ----
150    session.clear_pending_question();
151    session.metadata.remove("runtime.suspend_reason");
152    session.metadata.insert(
153        CLARIFICATION_RESUME_PENDING_KEY.to_string(),
154        "true".to_string(),
155    );
156    session.metadata.insert(
157        CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY.to_string(),
158        "true".to_string(),
159    );
160    mark_startup_handoff(&mut session);
161
162    // ---- Merge model/reasoning from request ----
163    let request_model_ref = derive_model_ref(
164        input.model_ref.as_ref(),
165        input.provider.as_deref(),
166        input.model.as_deref(),
167    );
168    if let Some(model_ref) = request_model_ref.as_ref() {
169        persist_model_ref(&mut session, model_ref);
170    } else {
171        persist_legacy_model_provider(
172            &mut session,
173            input.model.as_deref(),
174            input.provider.as_deref(),
175        );
176    }
177    if let Some(reasoning_effort) = input.reasoning_effort {
178        session.reasoning_effort = Some(reasoning_effort);
179    }
180
181    // ---- Save ----
182    repo.save_and_cache(&mut session).await?;
183
184    tracing::info!(
185        "[{}] Response processed successfully, agent loop can resume",
186        input.session_id
187    );
188
189    Ok((
190        session,
191        input.user_response,
192        plan_mode_transition,
193        permission_grants,
194    ))
195}
196
197/// Apply plan mode state transitions based on the pending question tool and user response.
198fn apply_plan_mode_transition(
199    session: &mut Session,
200    pending: &PendingQuestion,
201    user_response: &str,
202    reviewed_plan: Option<String>,
203) -> Option<PlanModeTransition> {
204    match pending.tool_name.as_str() {
205        "EnterPlanMode" if user_response.to_lowercase().contains("enter plan mode") => {
206            let pre_mode = session
207                .agent_runtime_state
208                .as_ref()
209                .map(|state| state.effective_permission_mode().as_str().to_string())
210                .unwrap_or_else(|| SessionPermissionMode::Default.as_str().to_string());
211
212            let entered_at = Utc::now();
213            let status = PlanModeStatus::Exploring;
214            let runtime_state = session
215                .agent_runtime_state
216                .get_or_insert_with(|| AgentRuntimeState::new(uuid::Uuid::new_v4().to_string()));
217            runtime_state.plan_mode = Some(PlanModeState {
218                entered_at,
219                pre_permission_mode: pre_mode.clone(),
220                plan_file_path: None,
221                status,
222            });
223            tracing::info!(
224                session_id = %session.id,
225                "Entered plan mode"
226            );
227            Some(PlanModeTransition::Entered {
228                reason: Some(pending.question.clone()),
229                pre_permission_mode: pre_mode,
230                entered_at,
231                status,
232                plan_file_path: None,
233            })
234        }
235        "ExitPlanMode" if is_exit_plan_mode_approved(user_response) => {
236            let restored_mode = session
237                .agent_runtime_state
238                .as_ref()
239                .map(|state| state.effective_permission_mode().as_str().to_string())
240                .unwrap_or_else(|| "default".to_string());
241            if let Some(ref mut runtime_state) = session.agent_runtime_state {
242                // The typed requested mode remains live while Plan is active and
243                // may have been changed by a newer PATCH. Exiting Plan clears
244                // only the overlay; the old pre-mode is event history, never a
245                // write authority that may roll back the newer request.
246                runtime_state.plan_mode = None;
247            }
248            tracing::info!(
249                session_id = %session.id,
250                "Exited plan mode"
251            );
252            Some(PlanModeTransition::Exited {
253                approved: true,
254                restored_mode,
255                plan: reviewed_plan,
256            })
257        }
258        _ => None,
259    }
260}
261
262/// Check if the user response approves exiting plan mode.
263fn is_exit_plan_mode_approved(user_response: &str) -> bool {
264    let lower = user_response.to_lowercase();
265    lower.contains("approve") && !lower.contains("stay in plan mode")
266}
267
268// ---- Internal helpers ----
269
270pub fn validate_pending_response(
271    pending: &PendingQuestion,
272    user_response: &str,
273) -> Result<(), String> {
274    if pending.allow_custom {
275        return Ok(());
276    }
277
278    let valid = pending.options.iter().any(|option| option == user_response);
279    if valid {
280        Ok(())
281    } else {
282        let options_str = pending.options.join(", ");
283        Err(format!("Response must be one of: {options_str}"))
284    }
285}
286
287pub fn update_or_append_tool_result_message(
288    session: &mut Session,
289    tool_call_id: &str,
290    user_response: &str,
291    response_source: ResponseSource,
292) -> bool {
293    for message in &mut session.messages {
294        if message.tool_call_id.as_deref() == Some(tool_call_id) {
295            message.content = selected_message_content(user_response, response_source);
296            message.tool_success = Some(true);
297            return true;
298        }
299    }
300
301    session.add_message(bamboo_agent_core::Message::tool_result_with_status(
302        tool_call_id,
303        selected_message_content(user_response, response_source),
304        true,
305    ));
306    false
307}
308
309fn selected_message_content(user_response: &str, response_source: ResponseSource) -> String {
310    match response_source {
311        ResponseSource::Human => format!("Selected response: {}", user_response),
312        ResponseSource::Gold => format!("Auto-selected response (gold): {}", user_response),
313    }
314}
315
316fn extract_exit_plan_from_tool_result_message(
317    session: &Session,
318    tool_call_id: &str,
319) -> Option<String> {
320    let message = session
321        .messages
322        .iter()
323        .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))?;
324    let payload = serde_json::from_str::<serde_json::Value>(&message.content).ok()?;
325    payload
326        .get("plan")
327        .and_then(|value| value.as_str())
328        .map(str::trim)
329        .filter(|value| !value.is_empty())
330        .map(ToOwned::to_owned)
331}
332
333/// Detect whether the user response approves a pending permission request.
334///
335/// Permission prompts (synthesized by the permission gate, and the
336/// `request_permissions` tool) offer exactly `["Approve", "Deny"]`.
337fn is_permission_approval(user_response: &str) -> bool {
338    user_response.trim().eq_ignore_ascii_case("approve")
339}
340
341/// Extract the permission grants implied by an approved permission prompt.
342///
343/// Reads the pending tool-result message (still the synthesized
344/// `awaiting_permission_approval` payload, before it is overwritten by the
345/// user's selection) and returns the `(PermissionType, resource)` pairs the
346/// caller should grant for the session. Handles both the single-gated-tool shape
347/// (top-level `permission_type` + `resource`) and the `request_permissions` shape
348/// (a `permissions` array).
349fn extract_permission_grants_from_tool_result_message(
350    session: &Session,
351    tool_call_id: &str,
352) -> Vec<(PermissionType, String)> {
353    let message = match session
354        .messages
355        .iter()
356        .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))
357    {
358        Some(message) => message,
359        None => return Vec::new(),
360    };
361    let payload = match serde_json::from_str::<serde_json::Value>(&message.content) {
362        Ok(payload) => payload,
363        Err(_) => return Vec::new(),
364    };
365    if payload.get("status").and_then(|value| value.as_str())
366        != Some("awaiting_permission_approval")
367    {
368        return Vec::new();
369    }
370
371    let parse_one = |value: &serde_json::Value| -> Option<(PermissionType, String)> {
372        let type_value = value
373            .get("permission_type")
374            .or_else(|| value.get("type"))?
375            .clone();
376        let perm_type: PermissionType = serde_json::from_value(type_value).ok()?;
377        let resource = value.get("resource")?.as_str()?.trim().to_string();
378        if resource.is_empty() {
379            return None;
380        }
381        Some((perm_type, resource))
382    };
383
384    if let Some(array) = payload
385        .get("permissions")
386        .and_then(|value| value.as_array())
387    {
388        array.iter().filter_map(parse_one).collect()
389    } else {
390        parse_one(&payload).into_iter().collect()
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    fn make_pending(tool_name: &str) -> PendingQuestion {
399        PendingQuestion {
400            tool_call_id: "call-1".to_string(),
401            tool_name: tool_name.to_string(),
402            question: "Question?".to_string(),
403            options: vec!["A".to_string(), "B".to_string()],
404            allow_custom: false,
405            source: bamboo_agent_core::PendingQuestionSource::PauseTool,
406        }
407    }
408
409    #[test]
410    fn enter_plan_mode_activates_plan_mode_state() {
411        let mut session = Session::new("sess-1", "test-model");
412        let pending = make_pending("EnterPlanMode");
413
414        apply_plan_mode_transition(&mut session, &pending, "Enter plan mode", None);
415
416        assert!(session.agent_runtime_state.is_some());
417        let state = session.agent_runtime_state.unwrap();
418        assert!(state.plan_mode.is_some());
419        let plan = state.plan_mode.unwrap();
420        assert_eq!(plan.status, PlanModeStatus::Exploring);
421        assert_eq!(plan.pre_permission_mode, "default");
422    }
423
424    #[test]
425    fn enter_plan_mode_does_nothing_when_not_approved() {
426        let mut session = Session::new("sess-1", "test-model");
427        let pending = make_pending("EnterPlanMode");
428
429        apply_plan_mode_transition(&mut session, &pending, "Stay in normal mode", None);
430
431        assert!(session.agent_runtime_state.is_none());
432    }
433
434    #[test]
435    fn plan_mode_preserves_and_restores_typed_auto_request() {
436        let mut session = Session::new("sess-auto-plan", "test-model");
437        session
438            .agent_runtime_state
439            .get_or_insert_with(|| AgentRuntimeState::new("run-1"))
440            .set_permission_mode(SessionPermissionMode::Auto);
441        let enter = make_pending("EnterPlanMode");
442        let transition =
443            apply_plan_mode_transition(&mut session, &enter, "Enter plan mode", None).unwrap();
444        assert!(matches!(
445            transition,
446            PlanModeTransition::Entered {
447                ref pre_permission_mode,
448                ..
449            } if pre_permission_mode == "auto"
450        ));
451        assert_eq!(
452            session
453                .agent_runtime_state
454                .as_ref()
455                .unwrap()
456                .plan_mode
457                .as_ref()
458                .unwrap()
459                .pre_permission_mode,
460            "auto"
461        );
462
463        let exit = make_pending("ExitPlanMode");
464        let transition = apply_plan_mode_transition(
465            &mut session,
466            &exit,
467            "Approve (Auto mode)",
468            Some("Reviewed plan".to_string()),
469        )
470        .unwrap();
471        assert!(matches!(
472            transition,
473            PlanModeTransition::Exited {
474                ref restored_mode,
475                ..
476            } if restored_mode == "auto"
477        ));
478        assert_eq!(
479            session
480                .agent_runtime_state
481                .as_ref()
482                .unwrap()
483                .effective_permission_mode(),
484            SessionPermissionMode::Auto
485        );
486    }
487
488    #[test]
489    fn exit_plan_mode_does_not_restore_over_a_newer_typed_mode() {
490        let mut session = Session::new("sess-plan-patch", "test-model");
491        let state = session
492            .agent_runtime_state
493            .get_or_insert_with(|| AgentRuntimeState::new("run-1"));
494        state.set_permission_mode(SessionPermissionMode::Auto);
495        state.plan_mode = Some(PlanModeState {
496            entered_at: Utc::now(),
497            pre_permission_mode: "auto".to_string(),
498            plan_file_path: None,
499            status: PlanModeStatus::AwaitingApproval,
500        });
501        // Simulate a newer PATCH while the Plan overlay is still active.
502        state.set_permission_mode(SessionPermissionMode::Bypass);
503
504        let transition = apply_plan_mode_transition(
505            &mut session,
506            &make_pending("ExitPlanMode"),
507            "Approve (Default mode)",
508            None,
509        )
510        .unwrap();
511
512        assert!(matches!(
513            transition,
514            PlanModeTransition::Exited {
515                ref restored_mode,
516                ..
517            } if restored_mode == "bypass"
518        ));
519        let state = session.agent_runtime_state.unwrap();
520        assert!(state.plan_mode.is_none());
521        assert_eq!(
522            state.effective_permission_mode(),
523            SessionPermissionMode::Bypass
524        );
525    }
526
527    #[test]
528    fn exit_plan_mode_clears_plan_mode_state() {
529        let mut session = Session::new("sess-1", "test-model");
530        session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
531        session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
532            entered_at: Utc::now(),
533            pre_permission_mode: "default".to_string(),
534            plan_file_path: None,
535            status: PlanModeStatus::AwaitingApproval,
536        });
537        let pending = make_pending("ExitPlanMode");
538
539        apply_plan_mode_transition(
540            &mut session,
541            &pending,
542            "Approve (Default mode)",
543            Some("Reviewed plan".to_string()),
544        );
545
546        assert!(session.agent_runtime_state.unwrap().plan_mode.is_none());
547    }
548
549    #[test]
550    fn exit_plan_mode_keeps_plan_mode_when_not_approved() {
551        let mut session = Session::new("sess-1", "test-model");
552        session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
553        session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
554            entered_at: Utc::now(),
555            pre_permission_mode: "default".to_string(),
556            plan_file_path: None,
557            status: PlanModeStatus::AwaitingApproval,
558        });
559        let pending = make_pending("ExitPlanMode");
560
561        apply_plan_mode_transition(&mut session, &pending, "Stay in plan mode", None);
562
563        assert!(session.agent_runtime_state.unwrap().plan_mode.is_some());
564    }
565
566    #[test]
567    fn exit_plan_mode_ignores_other_tools() {
568        let mut session = Session::new("sess-1", "test-model");
569        let pending = make_pending("ConclusionWithOptions");
570
571        apply_plan_mode_transition(&mut session, &pending, "Approve", None);
572
573        assert!(session.agent_runtime_state.is_none());
574    }
575
576    #[test]
577    fn is_exit_plan_mode_approved_detects_approval() {
578        assert!(is_exit_plan_mode_approved("Approve (Default mode)"));
579        assert!(is_exit_plan_mode_approved("Approve (Accept edits mode)"));
580        assert!(!is_exit_plan_mode_approved("Stay in plan mode"));
581        assert!(!is_exit_plan_mode_approved("Edit plan first"));
582    }
583
584    #[test]
585    fn extract_exit_plan_from_tool_result_message_reads_plan_payload() {
586        let mut session = Session::new("sess-1", "test-model");
587        let mut tool_message = bamboo_agent_core::Message::tool_result(
588            "call-1",
589            serde_json::json!({
590                "plan": "# Plan\n\n1. Step"
591            })
592            .to_string(),
593        );
594        tool_message.tool_success = Some(true);
595        session.add_message(tool_message);
596
597        let plan = extract_exit_plan_from_tool_result_message(&session, "call-1");
598        assert_eq!(plan.as_deref(), Some("# Plan\n\n1. Step"));
599    }
600}