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 chrono::Utc;
6
7use super::errors::RespondError;
8use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
9use super::repository::SessionAccess;
10use super::types::RespondInput;
11
12const CLARIFICATION_RESUME_PENDING_KEY: &str = "clarification_resume_pending";
13const CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY: &str = "conclusion_with_options_resume_pending";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ResponseSource {
17    Human,
18    Gold,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum PlanModeTransition {
23    Entered {
24        reason: Option<String>,
25        pre_permission_mode: String,
26        entered_at: chrono::DateTime<chrono::Utc>,
27        status: PlanModeStatus,
28        plan_file_path: Option<String>,
29    },
30    Exited {
31        approved: bool,
32        restored_mode: String,
33        plan: Option<String>,
34    },
35}
36
37/// Submit a pending response: load session, validate, update messages,
38/// apply plan mode transitions, persist, and return the updated session.
39///
40/// The caller (handler) is responsible for auto-resume triggering.
41pub async fn submit_pending_response(
42    repo: &dyn SessionAccess,
43    input: RespondInput,
44) -> Result<(Session, String, Option<PlanModeTransition>), RespondError> {
45    submit_pending_response_with_source(repo, input, ResponseSource::Human).await
46}
47
48pub async fn submit_pending_response_with_source(
49    repo: &dyn SessionAccess,
50    input: RespondInput,
51    response_source: ResponseSource,
52) -> Result<(Session, String, Option<PlanModeTransition>), RespondError> {
53    // ---- Load session (merged for respond to pick up in-memory pending question) ----
54    let mut session = repo
55        .load_merged(&input.session_id)
56        .await?
57        .ok_or_else(|| RespondError::NotFound(input.session_id.clone()))?;
58
59    // ---- Take pending question ----
60    let pending = session
61        .pending_question
62        .take()
63        .ok_or(RespondError::NoPendingQuestion)?;
64
65    // ---- Validate response ----
66    if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
67        // Put the pending question back when validation fails.
68        session.pending_question = Some(pending);
69        return Err(RespondError::InvalidResponse(error_message));
70    }
71
72    let tool_call_id = pending.tool_call_id.clone();
73    tracing::debug!(
74        "[{}] Looking for tool result message with tool_call_id: {}",
75        input.session_id,
76        tool_call_id
77    );
78
79    let reviewed_plan = extract_exit_plan_from_tool_result_message(&session, &tool_call_id);
80
81    // ---- Update or append tool result message ----
82    let found = update_or_append_tool_result_message(
83        &mut session,
84        &tool_call_id,
85        &input.user_response,
86        response_source,
87    );
88    if found {
89        tracing::info!(
90            "[{}] Updated existing tool result message",
91            input.session_id
92        );
93    } else {
94        tracing::warn!(
95            "[{}] Tool result message not found for tool_call_id: {}, added fallback message",
96            input.session_id,
97            tool_call_id
98        );
99    }
100
101    // ---- Plan mode state transitions ----
102    let plan_mode_transition =
103        apply_plan_mode_transition(&mut session, &pending, &input.user_response, reviewed_plan);
104
105    // ---- Clear pending question and set resume marker ----
106    session.clear_pending_question();
107    session.metadata.remove("runtime.suspend_reason");
108    session.metadata.insert(
109        CLARIFICATION_RESUME_PENDING_KEY.to_string(),
110        "true".to_string(),
111    );
112    session.metadata.insert(
113        CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY.to_string(),
114        "true".to_string(),
115    );
116
117    // ---- Merge model/reasoning from request ----
118    let request_model_ref = derive_model_ref(
119        input.model_ref.as_ref(),
120        input.provider.as_deref(),
121        input.model.as_deref(),
122    );
123    if let Some(model_ref) = request_model_ref.as_ref() {
124        persist_model_ref(&mut session, model_ref);
125    } else {
126        persist_legacy_model_provider(
127            &mut session,
128            input.model.as_deref(),
129            input.provider.as_deref(),
130        );
131    }
132    if let Some(reasoning_effort) = input.reasoning_effort {
133        session.reasoning_effort = Some(reasoning_effort);
134    }
135
136    // ---- Save ----
137    repo.save_and_cache(&mut session).await?;
138
139    tracing::info!(
140        "[{}] Response processed successfully, agent loop can resume",
141        input.session_id
142    );
143
144    Ok((session, input.user_response, plan_mode_transition))
145}
146
147/// Apply plan mode state transitions based on the pending question tool and user response.
148fn apply_plan_mode_transition(
149    session: &mut Session,
150    pending: &PendingQuestion,
151    user_response: &str,
152    reviewed_plan: Option<String>,
153) -> Option<PlanModeTransition> {
154    match pending.tool_name.as_str() {
155        "EnterPlanMode" if user_response.to_lowercase().contains("enter plan mode") => {
156            let pre_mode = session
157                .agent_runtime_state
158                .as_ref()
159                .and_then(|s| s.plan_mode.as_ref())
160                .map(|p| p.pre_permission_mode.clone())
161                .unwrap_or_else(|| "default".to_string());
162
163            let entered_at = Utc::now();
164            let status = PlanModeStatus::Exploring;
165            let runtime_state = session
166                .agent_runtime_state
167                .get_or_insert_with(|| AgentRuntimeState::new(uuid::Uuid::new_v4().to_string()));
168            runtime_state.plan_mode = Some(PlanModeState {
169                entered_at,
170                pre_permission_mode: pre_mode.clone(),
171                plan_file_path: None,
172                status,
173            });
174            tracing::info!(
175                session_id = %session.id,
176                "Entered plan mode"
177            );
178            Some(PlanModeTransition::Entered {
179                reason: Some(pending.question.clone()),
180                pre_permission_mode: pre_mode,
181                entered_at,
182                status,
183                plan_file_path: None,
184            })
185        }
186        "ExitPlanMode" if is_exit_plan_mode_approved(user_response) => {
187            let restored_mode = session
188                .agent_runtime_state
189                .as_ref()
190                .and_then(|state| state.plan_mode.as_ref())
191                .map(|plan| plan.pre_permission_mode.clone())
192                .unwrap_or_else(|| "default".to_string());
193            if let Some(ref mut runtime_state) = session.agent_runtime_state {
194                runtime_state.plan_mode = None;
195            }
196            tracing::info!(
197                session_id = %session.id,
198                "Exited plan mode"
199            );
200            Some(PlanModeTransition::Exited {
201                approved: true,
202                restored_mode,
203                plan: reviewed_plan,
204            })
205        }
206        _ => None,
207    }
208}
209
210/// Check if the user response approves exiting plan mode.
211fn is_exit_plan_mode_approved(user_response: &str) -> bool {
212    let lower = user_response.to_lowercase();
213    lower.contains("approve") && !lower.contains("stay in plan mode")
214}
215
216// ---- Internal helpers ----
217
218pub fn validate_pending_response(
219    pending: &PendingQuestion,
220    user_response: &str,
221) -> Result<(), String> {
222    if pending.allow_custom {
223        return Ok(());
224    }
225
226    let valid = pending.options.iter().any(|option| option == user_response);
227    if valid {
228        Ok(())
229    } else {
230        let options_str = pending.options.join(", ");
231        Err(format!("Response must be one of: {options_str}"))
232    }
233}
234
235pub fn update_or_append_tool_result_message(
236    session: &mut Session,
237    tool_call_id: &str,
238    user_response: &str,
239    response_source: ResponseSource,
240) -> bool {
241    for message in &mut session.messages {
242        if message.tool_call_id.as_deref() == Some(tool_call_id) {
243            message.content = selected_message_content(user_response, response_source);
244            message.tool_success = Some(true);
245            return true;
246        }
247    }
248
249    session.add_message(bamboo_agent_core::Message::tool_result_with_status(
250        tool_call_id,
251        selected_message_content(user_response, response_source),
252        true,
253    ));
254    false
255}
256
257fn selected_message_content(user_response: &str, response_source: ResponseSource) -> String {
258    match response_source {
259        ResponseSource::Human => format!("Selected response: {}", user_response),
260        ResponseSource::Gold => format!("Auto-selected response (gold): {}", user_response),
261    }
262}
263
264fn extract_exit_plan_from_tool_result_message(
265    session: &Session,
266    tool_call_id: &str,
267) -> Option<String> {
268    let message = session
269        .messages
270        .iter()
271        .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))?;
272    let payload = serde_json::from_str::<serde_json::Value>(&message.content).ok()?;
273    payload
274        .get("plan")
275        .and_then(|value| value.as_str())
276        .map(str::trim)
277        .filter(|value| !value.is_empty())
278        .map(ToOwned::to_owned)
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn make_pending(tool_name: &str) -> PendingQuestion {
286        PendingQuestion {
287            tool_call_id: "call-1".to_string(),
288            tool_name: tool_name.to_string(),
289            question: "Question?".to_string(),
290            options: vec!["A".to_string(), "B".to_string()],
291            allow_custom: false,
292            source: bamboo_agent_core::PendingQuestionSource::PauseTool,
293        }
294    }
295
296    #[test]
297    fn enter_plan_mode_activates_plan_mode_state() {
298        let mut session = Session::new("sess-1", "test-model");
299        let pending = make_pending("EnterPlanMode");
300
301        apply_plan_mode_transition(&mut session, &pending, "Enter plan mode", None);
302
303        assert!(session.agent_runtime_state.is_some());
304        let state = session.agent_runtime_state.unwrap();
305        assert!(state.plan_mode.is_some());
306        let plan = state.plan_mode.unwrap();
307        assert_eq!(plan.status, PlanModeStatus::Exploring);
308        assert_eq!(plan.pre_permission_mode, "default");
309    }
310
311    #[test]
312    fn enter_plan_mode_does_nothing_when_not_approved() {
313        let mut session = Session::new("sess-1", "test-model");
314        let pending = make_pending("EnterPlanMode");
315
316        apply_plan_mode_transition(&mut session, &pending, "Stay in normal mode", None);
317
318        assert!(session.agent_runtime_state.is_none());
319    }
320
321    #[test]
322    fn exit_plan_mode_clears_plan_mode_state() {
323        let mut session = Session::new("sess-1", "test-model");
324        session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
325        session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
326            entered_at: Utc::now(),
327            pre_permission_mode: "default".to_string(),
328            plan_file_path: None,
329            status: PlanModeStatus::AwaitingApproval,
330        });
331        let pending = make_pending("ExitPlanMode");
332
333        apply_plan_mode_transition(
334            &mut session,
335            &pending,
336            "Approve (Default mode)",
337            Some("Reviewed plan".to_string()),
338        );
339
340        assert!(session.agent_runtime_state.unwrap().plan_mode.is_none());
341    }
342
343    #[test]
344    fn exit_plan_mode_keeps_plan_mode_when_not_approved() {
345        let mut session = Session::new("sess-1", "test-model");
346        session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
347        session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
348            entered_at: Utc::now(),
349            pre_permission_mode: "default".to_string(),
350            plan_file_path: None,
351            status: PlanModeStatus::AwaitingApproval,
352        });
353        let pending = make_pending("ExitPlanMode");
354
355        apply_plan_mode_transition(&mut session, &pending, "Stay in plan mode", None);
356
357        assert!(session.agent_runtime_state.unwrap().plan_mode.is_some());
358    }
359
360    #[test]
361    fn exit_plan_mode_ignores_other_tools() {
362        let mut session = Session::new("sess-1", "test-model");
363        let pending = make_pending("ConclusionWithOptions");
364
365        apply_plan_mode_transition(&mut session, &pending, "Approve", None);
366
367        assert!(session.agent_runtime_state.is_none());
368    }
369
370    #[test]
371    fn is_exit_plan_mode_approved_detects_approval() {
372        assert!(is_exit_plan_mode_approved("Approve (Default mode)"));
373        assert!(is_exit_plan_mode_approved("Approve (Accept edits mode)"));
374        assert!(!is_exit_plan_mode_approved("Stay in plan mode"));
375        assert!(!is_exit_plan_mode_approved("Edit plan first"));
376    }
377
378    #[test]
379    fn extract_exit_plan_from_tool_result_message_reads_plan_payload() {
380        let mut session = Session::new("sess-1", "test-model");
381        let mut tool_message = bamboo_agent_core::Message::tool_result(
382            "call-1",
383            serde_json::json!({
384                "plan": "# Plan\n\n1. Step"
385            })
386            .to_string(),
387        );
388        tool_message.tool_success = Some(true);
389        session.add_message(tool_message);
390
391        let plan = extract_exit_plan_from_tool_result_message(&session, "call-1");
392        assert_eq!(plan.as_deref(), Some("# Plan\n\n1. Step"));
393    }
394}