1use bamboo_agent_core::{Message, Role};
4use bamboo_domain::reasoning::ReasoningEffort;
5use bamboo_domain::Session;
6
7use super::errors::ExecutePreparationError;
8use super::provider_model::{
9 persist_legacy_model_provider, persist_model_ref, session_effective_model_ref,
10};
11use super::repository::SessionAccess;
12use super::types::{
13 ExecuteClientSync, ExecuteInput, ExecutePreparationOutcome, ExecuteSyncReason,
14 ExecutionConfigSnapshot, ServerExecuteSnapshot,
15};
16
17pub async fn prepare_execute(
23 repo: &dyn SessionAccess,
24 config: ExecutionConfigSnapshot,
25 input: ExecuteInput,
26) -> Result<ExecutePreparationOutcome, ExecutePreparationError> {
27 let mut session = repo
29 .load_session(&input.session_id)
30 .await?
31 .ok_or_else(|| ExecutePreparationError::NotFound(input.session_id.clone()))?;
32
33 let is_child_session = session.kind == bamboo_agent_core::SessionKind::Child;
34 let server_snapshot = ServerExecuteSnapshot::from_session(&session);
35
36 if let Some(reason) = evaluate_client_sync(input.client_sync.as_ref(), &server_snapshot) {
38 match input.client_sync.as_ref() {
39 Some(cs) => tracing::debug!(
40 "[{}] Execute sync MISMATCH reason={:?}: client(count={}, last_id={:?}, pending_q={}, pq_tool={:?}) vs server(count={}, last_id={:?}, pending_q={}, pq_tool={:?}); total_messages_in_session={}",
41 input.session_id,
42 reason,
43 cs.client_message_count,
44 cs.client_last_message_id,
45 cs.client_has_pending_question,
46 cs.client_pending_question_tool_call_id,
47 server_snapshot.message_count,
48 server_snapshot.last_message_id,
49 server_snapshot.has_pending_question,
50 server_snapshot.pending_question_tool_call_id,
51 session.messages.len(),
52 ),
53 None => tracing::debug!(
54 "[{}] Execute sync MISMATCH reason={:?} but no client_sync was sent",
55 input.session_id,
56 reason
57 ),
58 }
59 return Ok(ExecutePreparationOutcome::SyncMismatch {
60 reason,
61 server_snapshot,
62 });
63 }
64
65 let (effective_model_ref, effective_model, model_source) = if config.provider_model_ref_enabled
69 {
70 resolve_model_ref_cascade(&session, &input, &config)
71 } else {
72 let (effective_model, model_source) = resolve_model_cascade(&session, &input, &config);
73 (None, effective_model, model_source)
74 };
75
76 let Some(effective_model) = effective_model else {
77 return Ok(ExecutePreparationOutcome::ModelRequired);
78 };
79
80 let (effective_reasoning_effort, reasoning_effort_source) = {
84 let (effort, source) = crate::model_areas::resolve_effective_reasoning_effort(
85 session.reasoning_effort,
86 input.request_reasoning_effort,
87 config.default_reasoning_effort,
88 );
89 (effort, source.as_str())
90 };
91
92 if let Err(error) =
94 validate_image_fallback_for_session(&session, config.image_fallback.as_ref())
95 {
96 return Ok(ExecutePreparationOutcome::ImageFallbackError(error));
97 }
98
99 if !server_snapshot.has_pending_user_message {
101 return Ok(ExecutePreparationOutcome::NoPendingMessage { server_snapshot });
102 }
103
104 if let Some(model_ref) = effective_model_ref.as_ref() {
106 persist_model_ref(&mut session, model_ref);
107 } else {
108 persist_legacy_model_provider(
109 &mut session,
110 Some(effective_model.as_str()),
111 Some(config.provider_name.as_str()),
112 );
113 }
114 session.reasoning_effort = effective_reasoning_effort;
115
116 session
117 .metadata
118 .insert("model_source".to_string(), model_source.to_string());
119
120 if effective_reasoning_effort.is_some() {
121 session.metadata.insert(
122 "reasoning_effort_source".to_string(),
123 reasoning_effort_source.to_string(),
124 );
125 session.metadata.insert(
126 "reasoning_effort_compat".to_string(),
127 effective_reasoning_effort
128 .map(ReasoningEffort::as_str)
129 .unwrap_or_default()
130 .to_string(),
131 );
132 } else {
133 session.metadata.remove("reasoning_effort_source");
134 session.metadata.remove("reasoning_effort_compat");
135 }
136
137 if let Some(skill_mode) = input.request_skill_mode {
139 let trimmed = skill_mode.trim();
140 if trimmed.is_empty() {
141 session.metadata.remove("skill_mode");
142 } else {
143 session
144 .metadata
145 .insert("skill_mode".to_string(), trimmed.to_string());
146 }
147 }
148
149 consume_pending_clarification_resume(&mut session);
151
152 Ok(ExecutePreparationOutcome::Ready {
153 session: Box::new(session),
154 effective_model,
155 effective_reasoning_effort,
156 model_source,
157 reasoning_source: reasoning_effort_source,
158 is_child_session,
159 })
160}
161
162pub(crate) fn resolve_model_cascade(
164 session: &Session,
165 input: &ExecuteInput,
166 config: &ExecutionConfigSnapshot,
167) -> (Option<String>, &'static str) {
168 let session_model = normalize_model(Some(session.model.as_str()));
169 let request_model = normalize_model(input.request_model.as_deref());
170 let request_model_used = request_model.is_some();
171 let model_source = if session_model.is_some() {
172 "session"
173 } else if config.default_model.is_some() {
174 "provider_default"
175 } else if request_model_used {
176 "request"
177 } else {
178 "none"
179 };
180 let effective_model = session_model
181 .or_else(|| config.default_model.clone())
182 .or(request_model);
183
184 (effective_model, model_source)
185}
186
187pub(crate) fn resolve_model_ref_cascade(
189 session: &Session,
190 input: &ExecuteInput,
191 config: &ExecutionConfigSnapshot,
192) -> (
193 Option<bamboo_domain::ProviderModelRef>,
194 Option<String>,
195 &'static str,
196) {
197 let session_model_ref = session_effective_model_ref(session);
198 let request_model_ref = super::provider_model::derive_model_ref(
199 input.request_model_ref.as_ref(),
200 input.request_provider.as_deref(),
201 input.request_model.as_deref(),
202 );
203 let config_model_ref = config.default_model_ref.clone();
204
205 let (effective_model_ref, model_source) = if let Some(model_ref) = session_model_ref {
206 (Some(model_ref), "session")
207 } else if let Some(model_ref) = request_model_ref {
208 (Some(model_ref), "request")
209 } else if let Some(model_ref) = config_model_ref {
210 (Some(model_ref), "provider_default")
211 } else {
212 (None, "none")
213 };
214
215 if let Some(model_ref) = effective_model_ref {
216 let effective_model = normalize_model(Some(model_ref.model.as_str()));
217 (Some(model_ref), effective_model, model_source)
218 } else {
219 let (effective_model, legacy_source) = resolve_model_cascade(session, input, config);
220 (None, effective_model, legacy_source)
221 }
222}
223
224fn normalize_model(model: Option<&str>) -> Option<String> {
227 model
228 .map(str::trim)
229 .filter(|m| !m.is_empty() && *m != "unknown")
230 .map(String::from)
231}
232
233pub fn evaluate_client_sync(
234 client_sync: Option<&ExecuteClientSync>,
235 server_snapshot: &ServerExecuteSnapshot,
236) -> Option<ExecuteSyncReason> {
237 let client_sync = client_sync?;
238
239 let client_pending_question_tool_call_id = client_sync
240 .client_pending_question_tool_call_id
241 .as_deref()
242 .map(str::trim)
243 .filter(|value| !value.is_empty());
244 let server_pending_question_tool_call_id = server_snapshot
245 .pending_question_tool_call_id
246 .as_deref()
247 .map(str::trim)
248 .filter(|value| !value.is_empty());
249
250 if client_sync.client_has_pending_question != server_snapshot.has_pending_question {
251 return Some(ExecuteSyncReason::PendingQuestionMismatch);
252 }
253
254 if client_sync.client_has_pending_question
255 && client_pending_question_tool_call_id.is_some()
256 && client_pending_question_tool_call_id != server_pending_question_tool_call_id
257 {
258 return Some(ExecuteSyncReason::PendingQuestionMismatch);
259 }
260
261 if client_sync.client_message_count != server_snapshot.message_count {
262 return Some(ExecuteSyncReason::MessageCountMismatch);
263 }
264
265 let client_last_message_id = client_sync
266 .client_last_message_id
267 .as_deref()
268 .map(str::trim)
269 .filter(|value| !value.is_empty());
270 let server_last_message_id = server_snapshot
271 .last_message_id
272 .as_deref()
273 .map(str::trim)
274 .filter(|value| !value.is_empty());
275
276 if client_last_message_id != server_last_message_id {
277 return Some(ExecuteSyncReason::LastMessageIdMismatch);
278 }
279
280 None
281}
282
283fn validate_image_fallback_for_session(
284 session: &Session,
285 image_fallback: Option<&bamboo_engine::ImageFallbackConfig>,
286) -> Result<(), String> {
287 use bamboo_engine::ImageFallbackMode;
288
289 if matches!(
290 image_fallback,
291 Some(bamboo_engine::ImageFallbackConfig {
292 mode: ImageFallbackMode::Error,
293 ..
294 })
295 ) {
296 let images_seen = session
297 .messages
298 .iter()
299 .filter_map(|message| message.content_parts.as_ref())
300 .flat_map(|parts| parts.iter())
301 .filter(|part| matches!(part, bamboo_agent_core::MessagePart::ImageUrl { .. }))
302 .count();
303
304 if images_seen > 0 {
305 return Err(format!(
306 "This server does not currently support image inputs (found {images_seen} image part(s)). \
307 Configure hooks.image_fallback.mode='placeholder' or 'ocr' to degrade gracefully."
308 ));
309 }
310 }
311
312 Ok(())
313}
314
315const CLARIFICATION_RESUME_PENDING_KEY: &str = "clarification_resume_pending";
316const CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY: &str = "conclusion_with_options_resume_pending";
317
318pub fn has_pending_user_message(session: &Session) -> bool {
320 if has_pending_clarification_resume(session) || has_pending_retry_resume(session) {
321 return true;
322 }
323 session
324 .messages
325 .last()
326 .map(|message| matches!(message.role, Role::User))
327 .unwrap_or(false)
328}
329
330pub fn consume_pending_clarification_resume(session: &mut Session) {
331 session.metadata.remove(CLARIFICATION_RESUME_PENDING_KEY);
332 session
333 .metadata
334 .remove(CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY);
335 session.metadata.remove("retry_resume_pending");
336 session.metadata.remove("retry_resume_reason");
337}
338
339pub fn has_pending_clarification_resume(session: &Session) -> bool {
340 session
341 .metadata
342 .get(CLARIFICATION_RESUME_PENDING_KEY)
343 .or_else(|| {
344 session
345 .metadata
346 .get(CONCLUSION_WITH_OPTIONS_RESUME_PENDING_KEY)
347 })
348 .is_some_and(|value| value == "true")
349}
350
351pub fn has_pending_conclusion_with_options_resume(session: &Session) -> bool {
352 has_pending_clarification_resume(session)
353}
354
355pub fn has_pending_retry_resume(session: &Session) -> bool {
356 session
357 .metadata
358 .get("retry_resume_pending")
359 .is_some_and(|value| value == "true")
360}
361
362pub fn consume_pending_conclusion_with_options_resume(session: &mut Session) {
363 consume_pending_clarification_resume(session)
364}
365
366pub(crate) fn is_hidden_from_ui(message: &Message) -> bool {
374 message
375 .metadata
376 .as_ref()
377 .and_then(|metadata| metadata.get("hidden_from_ui"))
378 .and_then(|value| value.as_bool())
379 .unwrap_or(false)
380}
381
382pub fn is_system_resume_message(message: &Message) -> bool {
401 if !matches!(message.role, Role::User) {
402 return false;
403 }
404 let Some(metadata) = message.metadata.as_ref() else {
405 return false;
406 };
407
408 if metadata
409 .get("hidden_from_ui")
410 .and_then(|value| value.as_bool())
411 .unwrap_or(false)
412 {
413 return true;
414 }
415
416 matches!(
417 metadata
418 .get("runtime_kind")
419 .and_then(|value| value.as_str()),
420 Some("child_completion_resume")
421 | Some("retry_resume")
422 | Some("conclusion_with_options_resume")
423 | Some("clarification_resume")
424 | Some("gold_continue_resume")
425 | Some("gold_goal_resume")
426 )
427}
428
429pub fn is_billable_user_turn(message: &Message) -> bool {
436 matches!(message.role, Role::User) && !is_system_resume_message(message)
437}
438
439pub fn billable_user_turn_count(session: &Session) -> usize {
443 session
444 .messages
445 .iter()
446 .filter(|message| is_billable_user_turn(message))
447 .count()
448}
449
450impl ServerExecuteSnapshot {
451 pub fn from_session(session: &Session) -> Self {
452 let visible: Vec<&Message> = session
457 .messages
458 .iter()
459 .filter(|message| !is_hidden_from_ui(message))
460 .collect();
461 Self {
462 message_count: visible.len(),
463 last_message_id: visible.last().map(|message| message.id.clone()),
464 has_pending_question: session.pending_question.is_some(),
465 pending_question_tool_call_id: session
466 .pending_question
467 .as_ref()
468 .map(|pending| pending.tool_call_id.clone()),
469 has_pending_user_message: has_pending_user_message(session),
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use bamboo_domain::ProviderModelRef;
478
479 fn make_session(model: &str) -> Session {
480 let mut s = Session::new("test-session", model);
481 s.messages.push(bamboo_agent_core::Message::user("hello"));
483 s
484 }
485
486 fn make_input() -> ExecuteInput {
487 ExecuteInput {
488 session_id: "test-session".to_string(),
489 request_model: None,
490 request_model_ref: None,
491 request_provider: None,
492 request_reasoning_effort: None,
493 request_skill_mode: None,
494 client_sync: None,
495 }
496 }
497
498 fn make_config() -> ExecutionConfigSnapshot {
499 ExecutionConfigSnapshot {
500 provider_model_ref_enabled: false,
501 ..Default::default()
502 }
503 }
504
505 #[test]
508 fn normalize_model_some() {
509 assert_eq!(normalize_model(Some("gpt-4")), Some("gpt-4".to_string()));
510 }
511
512 #[test]
513 fn normalize_model_trims_whitespace() {
514 assert_eq!(
515 normalize_model(Some(" gpt-4 ")),
516 Some("gpt-4".to_string())
517 );
518 }
519
520 #[test]
521 fn normalize_model_none() {
522 assert_eq!(normalize_model(None), None);
523 }
524
525 #[test]
526 fn normalize_model_empty() {
527 assert_eq!(normalize_model(Some("")), None);
528 }
529
530 #[test]
531 fn normalize_model_whitespace_only() {
532 assert_eq!(normalize_model(Some(" ")), None);
533 }
534
535 #[test]
536 fn normalize_model_unknown() {
537 assert_eq!(normalize_model(Some("unknown")), None);
538 }
539
540 #[test]
543 fn cascade_old_prefers_session_model() {
544 let session = make_session("claude-3");
545 let input = make_input();
546 let config = make_config();
547
548 let (model, source) = resolve_model_cascade(&session, &input, &config);
549 assert_eq!(model, Some("claude-3".to_string()));
550 assert_eq!(source, "session");
551 }
552
553 #[test]
554 fn cascade_old_falls_back_to_config_default() {
555 let session = make_session("unknown");
556 let input = make_input();
557 let mut config = make_config();
558 config.default_model = Some("gpt-4o".to_string());
559
560 let (model, source) = resolve_model_cascade(&session, &input, &config);
561 assert_eq!(model, Some("gpt-4o".to_string()));
562 assert_eq!(source, "provider_default");
563 }
564
565 #[test]
566 fn cascade_old_falls_back_to_request_model() {
567 let session = make_session("unknown");
568 let mut input = make_input();
569 input.request_model = Some("gpt-4-turbo".to_string());
570 let config = make_config();
571
572 let (model, source) = resolve_model_cascade(&session, &input, &config);
573 assert_eq!(model, Some("gpt-4-turbo".to_string()));
574 assert_eq!(source, "request");
575 }
576
577 #[test]
578 fn cascade_old_no_model_returns_none() {
579 let session = make_session("unknown");
580 let input = make_input();
581 let config = make_config();
582
583 let (model, source) = resolve_model_cascade(&session, &input, &config);
584 assert_eq!(model, None);
585 assert_eq!(source, "none");
586 }
587
588 #[test]
589 fn cascade_old_session_overrides_request() {
590 let session = make_session("claude-3");
591 let mut input = make_input();
592 input.request_model = Some("gpt-4".to_string());
593 let config = make_config();
594
595 let (model, source) = resolve_model_cascade(&session, &input, &config);
596 assert_eq!(model, Some("claude-3".to_string()));
597 assert_eq!(source, "session");
598 }
599
600 #[test]
603 fn cascade_new_prefers_session_model_ref() {
604 let mut session = make_session("unknown");
605 session.model_ref = Some(ProviderModelRef::new("anthropic", "claude-3"));
606 let input = make_input();
607 let mut config = make_config();
608 config.provider_model_ref_enabled = true;
609
610 let (model_ref, model, source) = resolve_model_ref_cascade(&session, &input, &config);
611 assert_eq!(
612 model_ref,
613 Some(ProviderModelRef::new("anthropic", "claude-3"))
614 );
615 assert_eq!(model, Some("claude-3".to_string()));
616 assert_eq!(source, "session");
617 }
618
619 #[test]
620 fn cascade_new_falls_back_to_request_model_ref_before_config_default_ref() {
621 let session = make_session("unknown");
622 let mut input = make_input();
623 input.request_model_ref = Some(ProviderModelRef::new("gemini", "gemini-pro"));
624 let mut config = make_config();
625 config.provider_model_ref_enabled = true;
626 config.default_model_ref = Some(ProviderModelRef::new("openai", "gpt-4o"));
627
628 let (model_ref, model, source) = resolve_model_ref_cascade(&session, &input, &config);
629 assert_eq!(
630 model_ref,
631 Some(ProviderModelRef::new("gemini", "gemini-pro"))
632 );
633 assert_eq!(model, Some("gemini-pro".to_string()));
634 assert_eq!(source, "request");
635 }
636
637 #[test]
638 fn cascade_new_falls_back_to_config_default_ref() {
639 let session = make_session("unknown");
640 let input = make_input();
641 let mut config = make_config();
642 config.provider_model_ref_enabled = true;
643 config.default_model_ref = Some(ProviderModelRef::new("openai", "gpt-4o"));
644
645 let (model_ref, model, source) = resolve_model_ref_cascade(&session, &input, &config);
646 assert_eq!(model_ref, Some(ProviderModelRef::new("openai", "gpt-4o")));
647 assert_eq!(model, Some("gpt-4o".to_string()));
648 assert_eq!(source, "provider_default");
649 }
650
651 #[test]
652 fn cascade_new_falls_back_to_old_cascade_when_no_refs() {
653 let mut session = make_session("claude-3");
654 session.model_ref = None;
655 let input = make_input();
656 let mut config = make_config();
657 config.provider_model_ref_enabled = true;
658
659 let (model_ref, model, source) = resolve_model_ref_cascade(&session, &input, &config);
660 assert_eq!(model_ref, None);
661 assert_eq!(model, Some("claude-3".to_string()));
662 assert_eq!(source, "session");
663 }
664
665 #[test]
666 fn cascade_new_session_ref_overrides_request_ref() {
667 let mut session = make_session("unknown");
668 session.model_ref = Some(ProviderModelRef::new("anthropic", "claude-3"));
669 let mut input = make_input();
670 input.request_model_ref = Some(ProviderModelRef::new("openai", "gpt-4o"));
671 let mut config = make_config();
672 config.provider_model_ref_enabled = true;
673
674 let (model_ref, model, source) = resolve_model_ref_cascade(&session, &input, &config);
675 assert_eq!(
676 model_ref,
677 Some(ProviderModelRef::new("anthropic", "claude-3"))
678 );
679 assert_eq!(model, Some("claude-3".to_string()));
680 assert_eq!(source, "session");
681 }
682
683 #[test]
684 fn cascade_new_uses_session_provider_metadata_even_without_structured_ref() {
685 let mut session = make_session("gpt-4o");
686 session.model_ref = None;
687 session
688 .metadata
689 .insert("provider_name".to_string(), "openai".to_string());
690 let input = make_input();
691 let mut config = make_config();
692 config.provider_model_ref_enabled = true;
693
694 let (model_ref, model, source) = resolve_model_ref_cascade(&session, &input, &config);
695 assert_eq!(model_ref, Some(ProviderModelRef::new("openai", "gpt-4o")));
696 assert_eq!(model, Some("gpt-4o".to_string()));
697 assert_eq!(source, "session");
698 }
699
700 #[test]
701 fn cascade_new_no_model_anywhere_returns_none() {
702 let session = make_session("unknown");
703 let input = make_input();
704 let mut config = make_config();
705 config.provider_model_ref_enabled = true;
706
707 let (model_ref, model, source) = resolve_model_ref_cascade(&session, &input, &config);
708 assert_eq!(model_ref, None);
709 assert_eq!(model, None);
710 assert_eq!(source, "none");
711 }
712
713 #[test]
716 fn sync_none_when_no_client_sync() {
717 let snapshot = ServerExecuteSnapshot {
718 message_count: 1,
719 last_message_id: Some("msg-1".to_string()),
720 has_pending_question: false,
721 pending_question_tool_call_id: None,
722 has_pending_user_message: true,
723 };
724 assert_eq!(evaluate_client_sync(None, &snapshot), None);
725 }
726
727 #[test]
728 fn sync_mismatch_pending_question_flag() {
729 let client_sync = ExecuteClientSync {
730 client_message_count: 1,
731 client_last_message_id: Some("msg-1".to_string()),
732 client_has_pending_question: true,
733 client_pending_question_tool_call_id: None,
734 };
735 let snapshot = ServerExecuteSnapshot {
736 message_count: 1,
737 last_message_id: Some("msg-1".to_string()),
738 has_pending_question: false,
739 pending_question_tool_call_id: None,
740 has_pending_user_message: true,
741 };
742 assert_eq!(
743 evaluate_client_sync(Some(&client_sync), &snapshot),
744 Some(ExecuteSyncReason::PendingQuestionMismatch)
745 );
746 }
747
748 #[test]
749 fn sync_mismatch_message_count() {
750 let client_sync = ExecuteClientSync {
751 client_message_count: 2,
752 client_last_message_id: Some("msg-2".to_string()),
753 client_has_pending_question: false,
754 client_pending_question_tool_call_id: None,
755 };
756 let snapshot = ServerExecuteSnapshot {
757 message_count: 1,
758 last_message_id: Some("msg-1".to_string()),
759 has_pending_question: false,
760 pending_question_tool_call_id: None,
761 has_pending_user_message: true,
762 };
763 assert_eq!(
764 evaluate_client_sync(Some(&client_sync), &snapshot),
765 Some(ExecuteSyncReason::MessageCountMismatch)
766 );
767 }
768
769 #[test]
770 fn sync_mismatch_last_message_id() {
771 let client_sync = ExecuteClientSync {
772 client_message_count: 1,
773 client_last_message_id: Some("msg-old".to_string()),
774 client_has_pending_question: false,
775 client_pending_question_tool_call_id: None,
776 };
777 let snapshot = ServerExecuteSnapshot {
778 message_count: 1,
779 last_message_id: Some("msg-new".to_string()),
780 has_pending_question: false,
781 pending_question_tool_call_id: None,
782 has_pending_user_message: true,
783 };
784 assert_eq!(
785 evaluate_client_sync(Some(&client_sync), &snapshot),
786 Some(ExecuteSyncReason::LastMessageIdMismatch)
787 );
788 }
789
790 #[test]
791 fn sync_ok_when_matching() {
792 let client_sync = ExecuteClientSync {
793 client_message_count: 1,
794 client_last_message_id: Some("msg-1".to_string()),
795 client_has_pending_question: false,
796 client_pending_question_tool_call_id: None,
797 };
798 let snapshot = ServerExecuteSnapshot {
799 message_count: 1,
800 last_message_id: Some("msg-1".to_string()),
801 has_pending_question: false,
802 pending_question_tool_call_id: None,
803 has_pending_user_message: true,
804 };
805 assert_eq!(evaluate_client_sync(Some(&client_sync), &snapshot), None);
806 }
807
808 #[test]
809 fn sync_ok_with_matching_pending_question_and_tool_call_id() {
810 let client_sync = ExecuteClientSync {
811 client_message_count: 2,
812 client_last_message_id: Some("msg-2".to_string()),
813 client_has_pending_question: true,
814 client_pending_question_tool_call_id: Some("tc-1".to_string()),
815 };
816 let snapshot = ServerExecuteSnapshot {
817 message_count: 2,
818 last_message_id: Some("msg-2".to_string()),
819 has_pending_question: true,
820 pending_question_tool_call_id: Some("tc-1".to_string()),
821 has_pending_user_message: false,
822 };
823 assert_eq!(evaluate_client_sync(Some(&client_sync), &snapshot), None);
824 }
825
826 #[test]
827 fn sync_mismatch_pending_question_tool_call_id() {
828 let client_sync = ExecuteClientSync {
829 client_message_count: 2,
830 client_last_message_id: Some("msg-2".to_string()),
831 client_has_pending_question: true,
832 client_pending_question_tool_call_id: Some("tc-old".to_string()),
833 };
834 let snapshot = ServerExecuteSnapshot {
835 message_count: 2,
836 last_message_id: Some("msg-2".to_string()),
837 has_pending_question: true,
838 pending_question_tool_call_id: Some("tc-new".to_string()),
839 has_pending_user_message: false,
840 };
841 assert_eq!(
842 evaluate_client_sync(Some(&client_sync), &snapshot),
843 Some(ExecuteSyncReason::PendingQuestionMismatch)
844 );
845 }
846
847 #[test]
850 fn pending_user_message_true_when_last_is_user() {
851 let session = make_session("gpt-4");
852 assert!(has_pending_user_message(&session));
853 }
854
855 #[test]
856 fn pending_user_message_false_when_last_is_assistant() {
857 let mut session = make_session("gpt-4");
858 session
859 .messages
860 .push(bamboo_agent_core::Message::assistant("response", None));
861 assert!(!has_pending_user_message(&session));
862 }
863
864 #[test]
865 fn pending_user_message_false_when_empty() {
866 let session = Session::new("test", "gpt-4");
867 assert!(!has_pending_user_message(&session));
868 }
869
870 #[test]
873 fn conclusion_with_options_resume_true() {
874 let mut session = Session::new("test", "gpt-4");
875 session.metadata.insert(
876 "conclusion_with_options_resume_pending".to_string(),
877 "true".to_string(),
878 );
879 assert!(has_pending_conclusion_with_options_resume(&session));
880 }
881
882 #[test]
883 fn conclusion_with_options_resume_false_when_missing() {
884 let session = Session::new("test", "gpt-4");
885 assert!(!has_pending_conclusion_with_options_resume(&session));
886 }
887
888 #[test]
889 fn conclusion_with_options_resume_false_when_not_true() {
890 let mut session = Session::new("test", "gpt-4");
891 session.metadata.insert(
892 "conclusion_with_options_resume_pending".to_string(),
893 "false".to_string(),
894 );
895 assert!(!has_pending_conclusion_with_options_resume(&session));
896 }
897
898 #[test]
899 fn retry_resume_true() {
900 let mut session = Session::new("test", "gpt-4");
901 session
902 .metadata
903 .insert("retry_resume_pending".to_string(), "true".to_string());
904 assert!(has_pending_retry_resume(&session));
905 }
906
907 #[test]
908 fn retry_resume_false_when_missing() {
909 let session = Session::new("test", "gpt-4");
910 assert!(!has_pending_retry_resume(&session));
911 }
912
913 #[test]
916 fn consume_removes_resume_metadata() {
917 let mut session = Session::new("test", "gpt-4");
918 session.metadata.insert(
919 "conclusion_with_options_resume_pending".to_string(),
920 "true".to_string(),
921 );
922 session
923 .metadata
924 .insert("retry_resume_pending".to_string(), "true".to_string());
925 session
926 .metadata
927 .insert("retry_resume_reason".to_string(), "timeout".to_string());
928
929 consume_pending_conclusion_with_options_resume(&mut session);
930
931 assert!(!session
932 .metadata
933 .contains_key("conclusion_with_options_resume_pending"));
934 assert!(!session.metadata.contains_key("retry_resume_pending"));
935 assert!(!session.metadata.contains_key("retry_resume_reason"));
936 }
937
938 #[test]
941 fn snapshot_from_session_counts_messages() {
942 let mut session = Session::new("test", "gpt-4");
943 session
944 .messages
945 .push(bamboo_agent_core::Message::user("hi"));
946 session
947 .messages
948 .push(bamboo_agent_core::Message::assistant("hello", None));
949 session.messages.last_mut().unwrap().id = "msg-2".to_string();
950
951 let snapshot = ServerExecuteSnapshot::from_session(&session);
952 assert_eq!(snapshot.message_count, 2);
953 assert_eq!(snapshot.last_message_id, Some("msg-2".to_string()));
954 assert!(!snapshot.has_pending_question);
955 assert!(!snapshot.has_pending_user_message);
956 }
957
958 #[test]
959 fn snapshot_empty_session() {
960 let session = Session::new("test", "gpt-4");
961 let snapshot = ServerExecuteSnapshot::from_session(&session);
962
963 assert_eq!(snapshot.message_count, 0);
964 assert_eq!(snapshot.last_message_id, None);
965 assert!(!snapshot.has_pending_question);
966 assert!(!snapshot.has_pending_user_message);
967 }
968
969 #[test]
970 fn snapshot_excludes_hidden_user_message() {
971 let mut session = Session::new("test", "gpt-4");
975 session.messages.push(Message::user("hi"));
976 session.messages.last_mut().unwrap().id = "msg-1".to_string();
977 session.messages.push(Message::assistant("hello", None));
978 session.messages.last_mut().unwrap().id = "msg-2".to_string();
979 let mut hidden = make_user_with_metadata(
980 "runtime",
981 serde_json::json!({
982 "hidden_from_ui": true,
983 "runtime_kind": "child_completion_resume",
984 }),
985 );
986 hidden.id = "msg-3".to_string();
987 session.messages.push(hidden);
988
989 let snapshot = ServerExecuteSnapshot::from_session(&session);
990 assert_eq!(snapshot.message_count, 2);
991 assert_eq!(snapshot.last_message_id, Some("msg-2".to_string()));
992 assert!(snapshot.has_pending_user_message);
995 }
996
997 #[test]
998 fn snapshot_excludes_trailing_hidden_message_for_last_id() {
999 let mut session = Session::new("test", "gpt-4");
1003 session.messages.push(Message::user("hi"));
1004 session.messages.last_mut().unwrap().id = "visible-user".to_string();
1005 session.messages.push(Message::assistant("hello", None));
1006 session.messages.last_mut().unwrap().id = "visible-assistant".to_string();
1007 let mut hidden =
1008 make_user_with_metadata("runtime", serde_json::json!({ "hidden_from_ui": true }));
1009 hidden.id = "hidden-tail".to_string();
1010 session.messages.push(hidden);
1011
1012 let snapshot = ServerExecuteSnapshot::from_session(&session);
1013 assert_eq!(
1014 snapshot.last_message_id,
1015 Some("visible-assistant".to_string())
1016 );
1017 }
1018
1019 #[test]
1020 fn evaluate_client_sync_passes_with_hidden_messages_filtered() {
1021 let mut session = Session::new("test", "gpt-4");
1026 session.messages.push(Message::user("hi"));
1027 session.messages.last_mut().unwrap().id = "msg-1".to_string();
1028 session.messages.push(Message::assistant("hello", None));
1029 session.messages.last_mut().unwrap().id = "msg-2".to_string();
1030 let mut hidden =
1031 make_user_with_metadata("runtime", serde_json::json!({ "hidden_from_ui": true }));
1032 hidden.id = "msg-3".to_string();
1033 session.messages.push(hidden);
1034
1035 let snapshot = ServerExecuteSnapshot::from_session(&session);
1036 let client_sync = ExecuteClientSync {
1037 client_message_count: 2,
1038 client_last_message_id: Some("msg-2".to_string()),
1039 client_has_pending_question: false,
1040 client_pending_question_tool_call_id: None,
1041 };
1042
1043 assert_eq!(evaluate_client_sync(Some(&client_sync), &snapshot), None);
1044 }
1045
1046 #[test]
1047 fn snapshot_visibility_matches_history_filter() {
1048 let mut session = Session::new("test", "gpt-4");
1055 let mut visible_user = Message::user("hello");
1056 visible_user.id = "v-1".to_string();
1057 session.messages.push(visible_user);
1058 let mut hidden_a =
1059 make_user_with_metadata("runtime", serde_json::json!({ "hidden_from_ui": true }));
1060 hidden_a.id = "h-1".to_string();
1061 session.messages.push(hidden_a);
1062 let mut visible_assistant = Message::assistant("world", None);
1063 visible_assistant.id = "v-2".to_string();
1064 session.messages.push(visible_assistant);
1065 let mut hidden_b = make_user_with_metadata(
1066 "runtime",
1067 serde_json::json!({
1068 "hidden_from_ui": true,
1069 "runtime_kind": "retry_resume",
1070 }),
1071 );
1072 hidden_b.id = "h-2".to_string();
1073 session.messages.push(hidden_b);
1074
1075 let history_visible: Vec<&Message> = session
1077 .messages
1078 .iter()
1079 .filter(|m| !is_hidden_from_ui(m))
1080 .collect();
1081
1082 let snapshot = ServerExecuteSnapshot::from_session(&session);
1083 assert_eq!(snapshot.message_count, history_visible.len());
1084 assert_eq!(
1085 snapshot.last_message_id,
1086 history_visible.last().map(|m| m.id.clone())
1087 );
1088 }
1089
1090 fn make_user_with_metadata(content: &str, metadata: serde_json::Value) -> Message {
1093 let mut msg = Message::user(content);
1094 msg.metadata = Some(metadata);
1095 msg
1096 }
1097
1098 #[test]
1099 fn is_system_resume_detects_hidden_from_ui_flag() {
1100 let msg = make_user_with_metadata("runtime", serde_json::json!({ "hidden_from_ui": true }));
1101 assert!(is_system_resume_message(&msg));
1102 assert!(!is_billable_user_turn(&msg));
1103 }
1104
1105 #[test]
1106 fn is_system_resume_detects_known_runtime_kinds() {
1107 for kind in [
1108 "child_completion_resume",
1109 "retry_resume",
1110 "conclusion_with_options_resume",
1111 "gold_continue_resume",
1112 "gold_goal_resume",
1113 ] {
1114 let msg =
1115 make_user_with_metadata("runtime", serde_json::json!({ "runtime_kind": kind }));
1116 assert!(
1117 is_system_resume_message(&msg),
1118 "expected runtime_kind={kind} to be detected"
1119 );
1120 assert!(!is_billable_user_turn(&msg));
1121 }
1122 }
1123
1124 #[test]
1125 fn is_system_resume_ignores_unknown_runtime_kinds() {
1126 let msg = make_user_with_metadata(
1127 "runtime",
1128 serde_json::json!({ "runtime_kind": "something_else" }),
1129 );
1130 assert!(!is_system_resume_message(&msg));
1131 assert!(is_billable_user_turn(&msg));
1132 }
1133
1134 #[test]
1135 fn is_system_resume_returns_false_for_plain_user_message() {
1136 let msg = Message::user("hello");
1137 assert!(!is_system_resume_message(&msg));
1138 assert!(is_billable_user_turn(&msg));
1139 }
1140
1141 #[test]
1142 fn is_system_resume_returns_false_for_assistant_messages() {
1143 let msg = Message::assistant("hi", None);
1144 assert!(!is_system_resume_message(&msg));
1145 assert!(!is_billable_user_turn(&msg));
1146 }
1147
1148 #[test]
1149 fn billable_user_turn_count_skips_runtime_messages() {
1150 let mut session = Session::new("test", "gpt-4");
1151 session.messages.push(Message::user("first"));
1152 session.messages.push(Message::assistant("response", None));
1153 session.messages.push(make_user_with_metadata(
1154 "runtime",
1155 serde_json::json!({
1156 "hidden_from_ui": true,
1157 "runtime_kind": "child_completion_resume",
1158 }),
1159 ));
1160 session
1161 .messages
1162 .push(Message::assistant("response 2", None));
1163 session.messages.push(Message::user("second"));
1164
1165 assert_eq!(billable_user_turn_count(&session), 2);
1166 }
1167}