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