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