bamboo_engine/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 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
37pub 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 let mut session = repo
55 .load_merged(&input.session_id)
56 .await?
57 .ok_or_else(|| RespondError::NotFound(input.session_id.clone()))?;
58
59 let pending = session
61 .pending_question
62 .take()
63 .ok_or(RespondError::NoPendingQuestion)?;
64
65 if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
67 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 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 let plan_mode_transition =
103 apply_plan_mode_transition(&mut session, &pending, &input.user_response, reviewed_plan);
104
105 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 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 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
147fn 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
210fn 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
216pub 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}