Skip to main content

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