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