bamboo_server/session_app/
respond.rs1use 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
30pub async fn submit_pending_response(
35 repo: &dyn SessionAccess,
36 input: RespondInput,
37) -> Result<(Session, String, Option<PlanModeTransition>), RespondError> {
38 let mut session = repo
40 .load_merged(&input.session_id)
41 .await?
42 .ok_or_else(|| RespondError::NotFound(input.session_id.clone()))?;
43
44 let pending = session
46 .pending_question
47 .take()
48 .ok_or(RespondError::NoPendingQuestion)?;
49
50 if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
52 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 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 let plan_mode_transition =
84 apply_plan_mode_transition(&mut session, &pending, &input.user_response, reviewed_plan);
85
86 session.clear_pending_question();
88 session.metadata.insert(
89 CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY.to_string(),
90 "true".to_string(),
91 );
92
93 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 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
123fn 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
186fn 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
192pub 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}