1use bamboo_agent_core::{PendingQuestion, Session};
4use bamboo_domain::session::runtime_state::{AgentRuntimeState, PlanModeState, PlanModeStatus};
5use bamboo_domain::SessionPermissionMode;
6use bamboo_tools::permission::PermissionType;
7use chrono::Utc;
8
9use super::errors::RespondError;
10use super::execute::mark_startup_handoff;
11use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
12use super::repository::SessionAccess;
13use super::types::RespondInput;
14
15const CLARIFICATION_RESUME_PENDING_KEY: &str = "clarification_resume_pending";
16const CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY: &str = "conclusion_with_options_resume_pending";
17
18pub const PERMISSION_REEXECUTE_METADATA_KEY: &str = "permission.reexecute_tool_call_id";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ResponseSource {
27 Human,
28 Gold,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PlanModeTransition {
33 Entered {
34 reason: Option<String>,
35 pre_permission_mode: String,
36 entered_at: chrono::DateTime<chrono::Utc>,
37 status: PlanModeStatus,
38 plan_file_path: Option<String>,
39 },
40 Exited {
41 approved: bool,
42 restored_mode: String,
43 plan: Option<String>,
44 },
45}
46
47pub async fn submit_pending_response(
52 repo: &dyn SessionAccess,
53 input: RespondInput,
54) -> Result<
55 (
56 Session,
57 String,
58 Option<PlanModeTransition>,
59 Vec<(PermissionType, String)>,
60 ),
61 RespondError,
62> {
63 submit_pending_response_with_source(repo, input, ResponseSource::Human).await
64}
65
66pub async fn submit_pending_response_with_source(
67 repo: &dyn SessionAccess,
68 input: RespondInput,
69 response_source: ResponseSource,
70) -> Result<
71 (
72 Session,
73 String,
74 Option<PlanModeTransition>,
75 Vec<(PermissionType, String)>,
76 ),
77 RespondError,
78> {
79 let mut session = repo
81 .load_merged(&input.session_id)
82 .await?
83 .ok_or_else(|| RespondError::NotFound(input.session_id.clone()))?;
84
85 let pending = session
87 .pending_question
88 .take()
89 .ok_or(RespondError::NoPendingQuestion)?;
90
91 if let Err(error_message) = validate_pending_response(&pending, &input.user_response) {
93 session.pending_question = Some(pending);
95 return Err(RespondError::InvalidResponse(error_message));
96 }
97
98 let tool_call_id = pending.tool_call_id.clone();
99 tracing::debug!(
100 "[{}] Looking for tool result message with tool_call_id: {}",
101 input.session_id,
102 tool_call_id
103 );
104
105 let reviewed_plan = extract_exit_plan_from_tool_result_message(&session, &tool_call_id);
106
107 let permission_grants = if is_permission_approval(&input.user_response) {
111 extract_permission_grants_from_tool_result_message(&session, &tool_call_id)
112 } else {
113 Vec::new()
114 };
115 if !permission_grants.is_empty() {
116 session.metadata.insert(
120 PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
121 tool_call_id.clone(),
122 );
123 }
124
125 let found = update_or_append_tool_result_message(
127 &mut session,
128 &tool_call_id,
129 &input.user_response,
130 response_source,
131 );
132 if found {
133 tracing::info!(
134 "[{}] Updated existing tool result message",
135 input.session_id
136 );
137 } else {
138 tracing::warn!(
139 "[{}] Tool result message not found for tool_call_id: {}, added fallback message",
140 input.session_id,
141 tool_call_id
142 );
143 }
144
145 let plan_mode_transition =
147 apply_plan_mode_transition(&mut session, &pending, &input.user_response, reviewed_plan);
148
149 session.clear_pending_question();
151 session.metadata.remove("runtime.suspend_reason");
152 session.metadata.insert(
153 CLARIFICATION_RESUME_PENDING_KEY.to_string(),
154 "true".to_string(),
155 );
156 session.metadata.insert(
157 CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY.to_string(),
158 "true".to_string(),
159 );
160 mark_startup_handoff(&mut session);
161
162 let request_model_ref = derive_model_ref(
164 input.model_ref.as_ref(),
165 input.provider.as_deref(),
166 input.model.as_deref(),
167 );
168 if let Some(model_ref) = request_model_ref.as_ref() {
169 persist_model_ref(&mut session, model_ref);
170 } else {
171 persist_legacy_model_provider(
172 &mut session,
173 input.model.as_deref(),
174 input.provider.as_deref(),
175 );
176 }
177 if let Some(reasoning_effort) = input.reasoning_effort {
178 session.reasoning_effort = Some(reasoning_effort);
179 }
180
181 repo.save_and_cache(&mut session).await?;
183
184 tracing::info!(
185 "[{}] Response processed successfully, agent loop can resume",
186 input.session_id
187 );
188
189 Ok((
190 session,
191 input.user_response,
192 plan_mode_transition,
193 permission_grants,
194 ))
195}
196
197fn apply_plan_mode_transition(
199 session: &mut Session,
200 pending: &PendingQuestion,
201 user_response: &str,
202 reviewed_plan: Option<String>,
203) -> Option<PlanModeTransition> {
204 match pending.tool_name.as_str() {
205 "EnterPlanMode" if user_response.to_lowercase().contains("enter plan mode") => {
206 let pre_mode = session
207 .agent_runtime_state
208 .as_ref()
209 .map(|state| state.effective_permission_mode().as_str().to_string())
210 .unwrap_or_else(|| SessionPermissionMode::Default.as_str().to_string());
211
212 let entered_at = Utc::now();
213 let status = PlanModeStatus::Exploring;
214 let runtime_state = session
215 .agent_runtime_state
216 .get_or_insert_with(|| AgentRuntimeState::new(uuid::Uuid::new_v4().to_string()));
217 runtime_state.plan_mode = Some(PlanModeState {
218 entered_at,
219 pre_permission_mode: pre_mode.clone(),
220 plan_file_path: None,
221 status,
222 });
223 tracing::info!(
224 session_id = %session.id,
225 "Entered plan mode"
226 );
227 Some(PlanModeTransition::Entered {
228 reason: Some(pending.question.clone()),
229 pre_permission_mode: pre_mode,
230 entered_at,
231 status,
232 plan_file_path: None,
233 })
234 }
235 "ExitPlanMode" if is_exit_plan_mode_approved(user_response) => {
236 let restored_mode = session
237 .agent_runtime_state
238 .as_ref()
239 .map(|state| state.effective_permission_mode().as_str().to_string())
240 .unwrap_or_else(|| "default".to_string());
241 if let Some(ref mut runtime_state) = session.agent_runtime_state {
242 runtime_state.plan_mode = None;
247 }
248 tracing::info!(
249 session_id = %session.id,
250 "Exited plan mode"
251 );
252 Some(PlanModeTransition::Exited {
253 approved: true,
254 restored_mode,
255 plan: reviewed_plan,
256 })
257 }
258 _ => None,
259 }
260}
261
262fn is_exit_plan_mode_approved(user_response: &str) -> bool {
264 let lower = user_response.to_lowercase();
265 lower.contains("approve") && !lower.contains("stay in plan mode")
266}
267
268pub fn validate_pending_response(
271 pending: &PendingQuestion,
272 user_response: &str,
273) -> Result<(), String> {
274 if pending.allow_custom {
275 return Ok(());
276 }
277
278 let valid = pending.options.iter().any(|option| option == user_response);
279 if valid {
280 Ok(())
281 } else {
282 let options_str = pending.options.join(", ");
283 Err(format!("Response must be one of: {options_str}"))
284 }
285}
286
287pub fn update_or_append_tool_result_message(
288 session: &mut Session,
289 tool_call_id: &str,
290 user_response: &str,
291 response_source: ResponseSource,
292) -> bool {
293 for message in &mut session.messages {
294 if message.tool_call_id.as_deref() == Some(tool_call_id) {
295 message.content = selected_message_content(user_response, response_source);
296 message.tool_success = Some(true);
297 return true;
298 }
299 }
300
301 session.add_message(bamboo_agent_core::Message::tool_result_with_status(
302 tool_call_id,
303 selected_message_content(user_response, response_source),
304 true,
305 ));
306 false
307}
308
309fn selected_message_content(user_response: &str, response_source: ResponseSource) -> String {
310 match response_source {
311 ResponseSource::Human => format!("Selected response: {}", user_response),
312 ResponseSource::Gold => format!("Auto-selected response (gold): {}", user_response),
313 }
314}
315
316fn extract_exit_plan_from_tool_result_message(
317 session: &Session,
318 tool_call_id: &str,
319) -> Option<String> {
320 let message = session
321 .messages
322 .iter()
323 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))?;
324 let payload = serde_json::from_str::<serde_json::Value>(&message.content).ok()?;
325 payload
326 .get("plan")
327 .and_then(|value| value.as_str())
328 .map(str::trim)
329 .filter(|value| !value.is_empty())
330 .map(ToOwned::to_owned)
331}
332
333fn is_permission_approval(user_response: &str) -> bool {
338 user_response.trim().eq_ignore_ascii_case("approve")
339}
340
341fn extract_permission_grants_from_tool_result_message(
350 session: &Session,
351 tool_call_id: &str,
352) -> Vec<(PermissionType, String)> {
353 let message = match session
354 .messages
355 .iter()
356 .find(|message| message.tool_call_id.as_deref() == Some(tool_call_id))
357 {
358 Some(message) => message,
359 None => return Vec::new(),
360 };
361 let payload = match serde_json::from_str::<serde_json::Value>(&message.content) {
362 Ok(payload) => payload,
363 Err(_) => return Vec::new(),
364 };
365 if payload.get("status").and_then(|value| value.as_str())
366 != Some("awaiting_permission_approval")
367 {
368 return Vec::new();
369 }
370
371 let parse_one = |value: &serde_json::Value| -> Option<(PermissionType, String)> {
372 let type_value = value
373 .get("permission_type")
374 .or_else(|| value.get("type"))?
375 .clone();
376 let perm_type: PermissionType = serde_json::from_value(type_value).ok()?;
377 let resource = value.get("resource")?.as_str()?.trim().to_string();
378 if resource.is_empty() {
379 return None;
380 }
381 Some((perm_type, resource))
382 };
383
384 if let Some(array) = payload
385 .get("permissions")
386 .and_then(|value| value.as_array())
387 {
388 array.iter().filter_map(parse_one).collect()
389 } else {
390 parse_one(&payload).into_iter().collect()
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 fn make_pending(tool_name: &str) -> PendingQuestion {
399 PendingQuestion {
400 tool_call_id: "call-1".to_string(),
401 tool_name: tool_name.to_string(),
402 question: "Question?".to_string(),
403 options: vec!["A".to_string(), "B".to_string()],
404 allow_custom: false,
405 source: bamboo_agent_core::PendingQuestionSource::PauseTool,
406 }
407 }
408
409 #[test]
410 fn enter_plan_mode_activates_plan_mode_state() {
411 let mut session = Session::new("sess-1", "test-model");
412 let pending = make_pending("EnterPlanMode");
413
414 apply_plan_mode_transition(&mut session, &pending, "Enter plan mode", None);
415
416 assert!(session.agent_runtime_state.is_some());
417 let state = session.agent_runtime_state.unwrap();
418 assert!(state.plan_mode.is_some());
419 let plan = state.plan_mode.unwrap();
420 assert_eq!(plan.status, PlanModeStatus::Exploring);
421 assert_eq!(plan.pre_permission_mode, "default");
422 }
423
424 #[test]
425 fn enter_plan_mode_does_nothing_when_not_approved() {
426 let mut session = Session::new("sess-1", "test-model");
427 let pending = make_pending("EnterPlanMode");
428
429 apply_plan_mode_transition(&mut session, &pending, "Stay in normal mode", None);
430
431 assert!(session.agent_runtime_state.is_none());
432 }
433
434 #[test]
435 fn plan_mode_preserves_and_restores_typed_auto_request() {
436 let mut session = Session::new("sess-auto-plan", "test-model");
437 session
438 .agent_runtime_state
439 .get_or_insert_with(|| AgentRuntimeState::new("run-1"))
440 .set_permission_mode(SessionPermissionMode::Auto);
441 let enter = make_pending("EnterPlanMode");
442 let transition =
443 apply_plan_mode_transition(&mut session, &enter, "Enter plan mode", None).unwrap();
444 assert!(matches!(
445 transition,
446 PlanModeTransition::Entered {
447 ref pre_permission_mode,
448 ..
449 } if pre_permission_mode == "auto"
450 ));
451 assert_eq!(
452 session
453 .agent_runtime_state
454 .as_ref()
455 .unwrap()
456 .plan_mode
457 .as_ref()
458 .unwrap()
459 .pre_permission_mode,
460 "auto"
461 );
462
463 let exit = make_pending("ExitPlanMode");
464 let transition = apply_plan_mode_transition(
465 &mut session,
466 &exit,
467 "Approve (Auto mode)",
468 Some("Reviewed plan".to_string()),
469 )
470 .unwrap();
471 assert!(matches!(
472 transition,
473 PlanModeTransition::Exited {
474 ref restored_mode,
475 ..
476 } if restored_mode == "auto"
477 ));
478 assert_eq!(
479 session
480 .agent_runtime_state
481 .as_ref()
482 .unwrap()
483 .effective_permission_mode(),
484 SessionPermissionMode::Auto
485 );
486 }
487
488 #[test]
489 fn exit_plan_mode_does_not_restore_over_a_newer_typed_mode() {
490 let mut session = Session::new("sess-plan-patch", "test-model");
491 let state = session
492 .agent_runtime_state
493 .get_or_insert_with(|| AgentRuntimeState::new("run-1"));
494 state.set_permission_mode(SessionPermissionMode::Auto);
495 state.plan_mode = Some(PlanModeState {
496 entered_at: Utc::now(),
497 pre_permission_mode: "auto".to_string(),
498 plan_file_path: None,
499 status: PlanModeStatus::AwaitingApproval,
500 });
501 state.set_permission_mode(SessionPermissionMode::Bypass);
503
504 let transition = apply_plan_mode_transition(
505 &mut session,
506 &make_pending("ExitPlanMode"),
507 "Approve (Default mode)",
508 None,
509 )
510 .unwrap();
511
512 assert!(matches!(
513 transition,
514 PlanModeTransition::Exited {
515 ref restored_mode,
516 ..
517 } if restored_mode == "bypass"
518 ));
519 let state = session.agent_runtime_state.unwrap();
520 assert!(state.plan_mode.is_none());
521 assert_eq!(
522 state.effective_permission_mode(),
523 SessionPermissionMode::Bypass
524 );
525 }
526
527 #[test]
528 fn exit_plan_mode_clears_plan_mode_state() {
529 let mut session = Session::new("sess-1", "test-model");
530 session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
531 session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
532 entered_at: Utc::now(),
533 pre_permission_mode: "default".to_string(),
534 plan_file_path: None,
535 status: PlanModeStatus::AwaitingApproval,
536 });
537 let pending = make_pending("ExitPlanMode");
538
539 apply_plan_mode_transition(
540 &mut session,
541 &pending,
542 "Approve (Default mode)",
543 Some("Reviewed plan".to_string()),
544 );
545
546 assert!(session.agent_runtime_state.unwrap().plan_mode.is_none());
547 }
548
549 #[test]
550 fn exit_plan_mode_keeps_plan_mode_when_not_approved() {
551 let mut session = Session::new("sess-1", "test-model");
552 session.agent_runtime_state = Some(AgentRuntimeState::new("run-1"));
553 session.agent_runtime_state.as_mut().unwrap().plan_mode = Some(PlanModeState {
554 entered_at: Utc::now(),
555 pre_permission_mode: "default".to_string(),
556 plan_file_path: None,
557 status: PlanModeStatus::AwaitingApproval,
558 });
559 let pending = make_pending("ExitPlanMode");
560
561 apply_plan_mode_transition(&mut session, &pending, "Stay in plan mode", None);
562
563 assert!(session.agent_runtime_state.unwrap().plan_mode.is_some());
564 }
565
566 #[test]
567 fn exit_plan_mode_ignores_other_tools() {
568 let mut session = Session::new("sess-1", "test-model");
569 let pending = make_pending("ConclusionWithOptions");
570
571 apply_plan_mode_transition(&mut session, &pending, "Approve", None);
572
573 assert!(session.agent_runtime_state.is_none());
574 }
575
576 #[test]
577 fn is_exit_plan_mode_approved_detects_approval() {
578 assert!(is_exit_plan_mode_approved("Approve (Default mode)"));
579 assert!(is_exit_plan_mode_approved("Approve (Accept edits mode)"));
580 assert!(!is_exit_plan_mode_approved("Stay in plan mode"));
581 assert!(!is_exit_plan_mode_approved("Edit plan first"));
582 }
583
584 #[test]
585 fn extract_exit_plan_from_tool_result_message_reads_plan_payload() {
586 let mut session = Session::new("sess-1", "test-model");
587 let mut tool_message = bamboo_agent_core::Message::tool_result(
588 "call-1",
589 serde_json::json!({
590 "plan": "# Plan\n\n1. Step"
591 })
592 .to_string(),
593 );
594 tool_message.tool_success = Some(true);
595 session.add_message(tool_message);
596
597 let plan = extract_exit_plan_from_tool_result_message(&session, "call-1");
598 assert_eq!(plan.as_deref(), Some("# Plan\n\n1. Step"));
599 }
600}