1use 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::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
10use super::repository::SessionAccess;
11use super::types::RespondInput;
12
13const CLARIFICATION_RESUME_PENDING_KEY: &str = "clarification_resume_pending";
14const CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY: &str = "conclusion_with_options_resume_pending";
15
16pub const PERMISSION_REEXECUTE_METADATA_KEY: &str = "permission.reexecute_tool_call_id";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ResponseSource {
25 Human,
26 Gold,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum PlanModeTransition {
31 Entered {
32 reason: Option<String>,
33 pre_permission_mode: String,
34 entered_at: chrono::DateTime<chrono::Utc>,
35 status: PlanModeStatus,
36 plan_file_path: Option<String>,
37 },
38 Exited {
39 approved: bool,
40 restored_mode: String,
41 plan: Option<String>,
42 },
43}
44
45pub async fn submit_pending_response(
50 repo: &dyn SessionAccess,
51 input: RespondInput,
52) -> Result<
53 (
54 Session,
55 String,
56 Option<PlanModeTransition>,
57 Vec<(PermissionType, String)>,
58 ),
59 RespondError,
60> {
61 submit_pending_response_with_source(repo, input, ResponseSource::Human).await
62}
63
64pub async fn submit_pending_response_with_source(
65 repo: &dyn SessionAccess,
66 input: RespondInput,
67 response_source: ResponseSource,
68) -> Result<
69 (
70 Session,
71 String,
72 Option<PlanModeTransition>,
73 Vec<(PermissionType, String)>,
74 ),
75 RespondError,
76> {
77 let mut session = repo
79 .load_merged(&input.session_id)
80 .await?
81 .ok_or_else(|| RespondError::NotFound(input.session_id.clone()))?;
82
83 let pending = session
85 .pending_question
86 .take()
87 .ok_or(RespondError::NoPendingQuestion)?;
88
89 if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
91 session.pending_question = Some(pending);
93 return Err(RespondError::InvalidResponse(error_message));
94 }
95
96 let tool_call_id = pending.tool_call_id.clone();
97 tracing::debug!(
98 "[{}] Looking for tool result message with tool_call_id: {}",
99 input.session_id,
100 tool_call_id
101 );
102
103 let reviewed_plan = extract_exit_plan_from_tool_result_message(&session, &tool_call_id);
104
105 let permission_grants = if is_permission_approval(&input.user_response) {
109 extract_permission_grants_from_tool_result_message(&session, &tool_call_id)
110 } else {
111 Vec::new()
112 };
113 if !permission_grants.is_empty() {
114 session.metadata.insert(
118 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
119 tool_call_id.clone(),
120 );
121 }
122
123 let found = update_or_append_tool_result_message(
125 &mut session,
126 &tool_call_id,
127 &input.user_response,
128 response_source,
129 );
130 if found {
131 tracing::info!(
132 "[{}] Updated existing tool result message",
133 input.session_id
134 );
135 } else {
136 tracing::warn!(
137 "[{}] Tool result message not found for tool_call_id: {}, added fallback message",
138 input.session_id,
139 tool_call_id
140 );
141 }
142
143 let plan_mode_transition =
145 apply_plan_mode_transition(&mut session, &pending, &input.user_response, reviewed_plan);
146
147 session.clear_pending_question();
149 session.metadata.remove("runtime.suspend_reason");
150 session.metadata.insert(
151 CLARIFICATION_RESUME_PENDING_KEY.to_string(),
152 "true".to_string(),
153 );
154 session.metadata.insert(
155 CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY.to_string(),
156 "true".to_string(),
157 );
158
159 let request_model_ref = derive_model_ref(
161 input.model_ref.as_ref(),
162 input.provider.as_deref(),
163 input.model.as_deref(),
164 );
165 if let Some(model_ref) = request_model_ref.as_ref() {
166 persist_model_ref(&mut session, model_ref);
167 } else {
168 persist_legacy_model_provider(
169 &mut session,
170 input.model.as_deref(),
171 input.provider.as_deref(),
172 );
173 }
174 if let Some(reasoning_effort) = input.reasoning_effort {
175 session.reasoning_effort = Some(reasoning_effort);
176 }
177
178 repo.save_and_cache(&mut session).await?;
180
181 tracing::info!(
182 "[{}] Response processed successfully, agent loop can resume",
183 input.session_id
184 );
185
186 Ok((
187 session,
188 input.user_response,
189 plan_mode_transition,
190 permission_grants,
191 ))
192}
193
194fn apply_plan_mode_transition(
196 session: &mut Session,
197 pending: &PendingQuestion,
198 user_response: &str,
199 reviewed_plan: Option<String>,
200) -> Option<PlanModeTransition> {
201 match pending.tool_name.as_str() {
202 "EnterPlanMode" if user_response.to_lowercase().contains("enter plan mode") => {
203 let pre_mode = session
204 .agent_runtime_state
205 .as_ref()
206 .and_then(|s| s.plan_mode.as_ref())
207 .map(|p| p.pre_permission_mode.clone())
208 .unwrap_or_else(|| "default".to_string());
209
210 let entered_at = Utc::now();
211 let status = PlanModeStatus::Exploring;
212 let runtime_state = session
213 .agent_runtime_state
214 .get_or_insert_with(|| AgentRuntimeState::new(uuid::Uuid::new_v4().to_string()));
215 runtime_state.plan_mode = Some(PlanModeState {
216 entered_at,
217 pre_permission_mode: pre_mode.clone(),
218 plan_file_path: None,
219 status,
220 });
221 tracing::info!(
222 session_id = %session.id,
223 "Entered plan mode"
224 );
225 Some(PlanModeTransition::Entered {
226 reason: Some(pending.question.clone()),
227 pre_permission_mode: pre_mode,
228 entered_at,
229 status,
230 plan_file_path: None,
231 })
232 }
233 "ExitPlanMode" if is_exit_plan_mode_approved(user_response) => {
234 let restored_mode = session
235 .agent_runtime_state
236 .as_ref()
237 .and_then(|state| state.plan_mode.as_ref())
238 .map(|plan| plan.pre_permission_mode.clone())
239 .unwrap_or_else(|| "default".to_string());
240 if let Some(ref mut runtime_state) = session.agent_runtime_state {
241 runtime_state.plan_mode = None;
242 }
243 tracing::info!(
244 session_id = %session.id,
245 "Exited plan mode"
246 );
247 Some(PlanModeTransition::Exited {
248 approved: true,
249 restored_mode,
250 plan: reviewed_plan,
251 })
252 }
253 _ => None,
254 }
255}
256
257fn is_exit_plan_mode_approved(user_response: &str) -> bool {
259 let lower = user_response.to_lowercase();
260 lower.contains("approve") && !lower.contains("stay in plan mode")
261}
262
263pub fn validate_pending_response(
266 pending: &PendingQuestion,
267 user_response: &str,
268) -> Result<(), String> {
269 if pending.allow_custom {
270 return Ok(());
271 }
272
273 let valid = pending.options.iter().any(|option| option == user_response);
274 if valid {
275 Ok(())
276 } else {
277 let options_str = pending.options.join(", ");
278 Err(format!("Response must be one of: {options_str}"))
279 }
280}
281
282pub fn update_or_append_tool_result_message(
283 session: &mut Session,
284 tool_call_id: &str,
285 user_response: &str,
286 response_source: ResponseSource,
287) -> bool {
288 for message in &mut session.messages {
289 if message.tool_call_id.as_deref() == Some(tool_call_id) {
290 message.content = selected_message_content(user_response, response_source);
291 message.tool_success = Some(true);
292 return true;
293 }
294 }
295
296 session.add_message(bamboo_agent_core::Message::tool_result_with_status(
297 tool_call_id,
298 selected_message_content(user_response, response_source),
299 true,
300 ));
301 false
302}
303
304fn selected_message_content(user_response: &str, response_source: ResponseSource) -> String {
305 match response_source {
306 ResponseSource::Human => format!("Selected response: {}", user_response),
307 ResponseSource::Gold => format!("Auto-selected response (gold): {}", user_response),
308 }
309}
310
311fn extract_exit_plan_from_tool_result_message(
312 session: &Session,
313 tool_call_id: &str,
314) -> Option<String> {
315 let message = session
316 .messages
317 .iter()
318 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))?;
319 let payload = serde_json::from_str::<serde_json::Value>(&message.content).ok()?;
320 payload
321 .get("plan")
322 .and_then(|value| value.as_str())
323 .map(str::trim)
324 .filter(|value| !value.is_empty())
325 .map(ToOwned::to_owned)
326}
327
328fn is_permission_approval(user_response: &str) -> bool {
333 user_response.trim().eq_ignore_ascii_case("approve")
334}
335
336fn extract_permission_grants_from_tool_result_message(
345 session: &Session,
346 tool_call_id: &str,
347) -> Vec<(PermissionType, String)> {
348 let message = match session
349 .messages
350 .iter()
351 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))
352 {
353 Some(message) => message,
354 None => return Vec::new(),
355 };
356 let payload = match serde_json::from_str::<serde_json::Value>(&message.content) {
357 Ok(payload) => payload,
358 Err(_) => return Vec::new(),
359 };
360 if payload.get("status").and_then(|value| value.as_str())
361 != Some("awaiting_permission_approval")
362 {
363 return Vec::new();
364 }
365
366 let parse_one = |value: &serde_json::Value| -> Option<(PermissionType, String)> {
367 let type_value = value
368 .get("permission_type")
369 .or_else(|| value.get("type"))?
370 .clone();
371 let perm_type: PermissionType = serde_json::from_value(type_value).ok()?;
372 let resource = value.get("resource")?.as_str()?.trim().to_string();
373 if resource.is_empty() {
374 return None;
375 }
376 Some((perm_type, resource))
377 };
378
379 if let Some(array) = payload
380 .get("permissions")
381 .and_then(|value| value.as_array())
382 {
383 array.iter().filter_map(parse_one).collect()
384 } else {
385 parse_one(&payload).into_iter().collect()
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn make_pending(tool_name: &str) -> PendingQuestion {
394 PendingQuestion {
395 tool_call_id: "call-1".to_string(),
396 tool_name: tool_name.to_string(),
397 question: "Question?".to_string(),
398 options: vec!["A".to_string(), "B".to_string()],
399 allow_custom: false,
400 source: bamboo_agent_core::PendingQuestionSource::PauseTool,
401 }
402 }
403
404 #[test]
405 fn enter_plan_mode_activates_plan_mode_state() {
406 let mut session = Session::new("sess-1", "test-model");
407 let pending = make_pending("EnterPlanMode");
408
409 apply_plan_mode_transition(&mut session, &pending, "Enter plan mode", None);
410
411 assert!(session.agent_runtime_state.is_some());
412 let state = session.agent_runtime_state.unwrap();
413 assert!(state.plan_mode.is_some());
414 let plan = state.plan_mode.unwrap();
415 assert_eq!(plan.status, PlanModeStatus::Exploring);
416 assert_eq!(plan.pre_permission_mode, "default");
417 }
418
419 #[test]
420 fn enter_plan_mode_does_nothing_when_not_approved() {
421 let mut session = Session::new("sess-1", "test-model");
422 let pending = make_pending("EnterPlanMode");
423
424 apply_plan_mode_transition(&mut session, &pending, "Stay in normal mode", None);
425
426 assert!(session.agent_runtime_state.is_none());
427 }
428
429 #[test]
430 fn exit_plan_mode_clears_plan_mode_state() {
431 let mut session = Session::new("sess-1", "test-model");
432 session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
433 session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
434 entered_at: Utc::now(),
435 pre_permission_mode: "default".to_string(),
436 plan_file_path: None,
437 status: PlanModeStatus::AwaitingApproval,
438 });
439 let pending = make_pending("ExitPlanMode");
440
441 apply_plan_mode_transition(
442 &mut session,
443 &pending,
444 "Approve (Default mode)",
445 Some("Reviewed plan".to_string()),
446 );
447
448 assert!(session.agent_runtime_state.unwrap().plan_mode.is_none());
449 }
450
451 #[test]
452 fn exit_plan_mode_keeps_plan_mode_when_not_approved() {
453 let mut session = Session::new("sess-1", "test-model");
454 session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
455 session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
456 entered_at: Utc::now(),
457 pre_permission_mode: "default".to_string(),
458 plan_file_path: None,
459 status: PlanModeStatus::AwaitingApproval,
460 });
461 let pending = make_pending("ExitPlanMode");
462
463 apply_plan_mode_transition(&mut session, &pending, "Stay in plan mode", None);
464
465 assert!(session.agent_runtime_state.unwrap().plan_mode.is_some());
466 }
467
468 #[test]
469 fn exit_plan_mode_ignores_other_tools() {
470 let mut session = Session::new("sess-1", "test-model");
471 let pending = make_pending("ConclusionWithOptions");
472
473 apply_plan_mode_transition(&mut session, &pending, "Approve", None);
474
475 assert!(session.agent_runtime_state.is_none());
476 }
477
478 #[test]
479 fn is_exit_plan_mode_approved_detects_approval() {
480 assert!(is_exit_plan_mode_approved("Approve (Default mode)"));
481 assert!(is_exit_plan_mode_approved("Approve (Accept edits mode)"));
482 assert!(!is_exit_plan_mode_approved("Stay in plan mode"));
483 assert!(!is_exit_plan_mode_approved("Edit plan first"));
484 }
485
486 #[test]
487 fn extract_exit_plan_from_tool_result_message_reads_plan_payload() {
488 let mut session = Session::new("sess-1", "test-model");
489 let mut tool_message = bamboo_agent_core::Message::tool_result(
490 "call-1",
491 serde_json::json!({
492 "plan": "# Plan\n\n1. Step"
493 })
494 .to_string(),
495 );
496 tool_message.tool_success = Some(true);
497 session.add_message(tool_message);
498
499 let plan = extract_exit_plan_from_tool_result_message(&session, "call-1");
500 assert_eq!(plan.as_deref(), Some("# Plan\n\n1. Step"));
501 }
502}