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