Skip to main content

bamboo_server/session_app/
execute.rs

1//! Execute use case: prepare a session for agent execution.
2
3use 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
17/// Prepare an execute: load session, resolve model/reasoning, validate,
18/// update metadata, return outcome.
19///
20/// The caller (handler) is responsible for runner reservation and agent spawning
21/// based on the returned outcome.
22pub async fn prepare_execute(
23    repo: &dyn SessionAccess,
24    config: ExecutionConfigSnapshot,
25    input: ExecuteInput,
26) -> Result<ExecutePreparationOutcome, ExecutePreparationError> {
27    // ---- Load session ----
28    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    // ---- Client sync check ----
37    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    // ---- Resolve model cascade ----
66    // Flag ON (new): session.model_ref → request.model_ref → config.default_model_ref
67    // Flag OFF (old): session.model → config.default_model → request.model
68    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    // ---- Resolve reasoning effort cascade: session → request → provider default ----
81    // Single shared cascade (see `crate::model_areas`). Stays `Option` so
82    // non-reasoning models send no reasoning parameter.
83    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    // ---- Image fallback validation ----
93    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    // ---- Check for pending user message ----
100    if !server_snapshot.has_pending_user_message {
101        return Ok(ExecutePreparationOutcome::NoPendingMessage { server_snapshot });
102    }
103
104    // ---- Update session metadata ----
105    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    // ---- Skill mode ----
138    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 markers ----
150    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
162/// Old-path model resolution: session.model → config.default_model → request.model
163pub(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
187/// New-path model resolution: session.model_ref → request.model_ref → config.default_model_ref.
188pub(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
224// ---- Internal helpers ----
225
226fn 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
318/// Whether the session has resumable user work (pending tool response, retry, or last message is from user).
319pub 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
366/// Returns true if the message is flagged `metadata.hidden_from_ui == true`.
367///
368/// Such messages are runtime-injected (e.g. child-completion resume, retry
369/// resume, conclusion-with-options resume) and are filtered out of any
370/// client-facing view. Both `GET /history` and the execute sync snapshot
371/// must use this exact predicate so the client and server agree on the
372/// visible message_count and last_message_id.
373pub(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
382// ---------------------------------------------------------------------------
383// Billing helpers
384// ---------------------------------------------------------------------------
385//
386// Some upstream products bill on "user-initiated message turns" rather than on
387// LLM request count. With the runtime-level suspend/resume model used by the
388// SubAgent tool, the engine can append `Role::User` messages that are NOT
389// caused by the human user (e.g. child-completion resume, retry resume,
390// conclusion_with_options resume). These helpers let a billing layer count
391// only genuine user turns and skip system-injected resume messages.
392
393/// Returns true if the message was synthesized by the runtime to resume a
394/// suspended root session (child completion, retry, conclusion-with-options,
395/// gold auto-continue).
396///
397/// Such messages have one or both of the following stable markers:
398/// - `metadata.hidden_from_ui == true`
399/// - `metadata.runtime_kind` set to a known resume kind
400pub 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
429/// Returns true when the message represents a billable end-user turn.
430///
431/// Use this in any per-message billing accounting (e.g. "1 user message =
432/// 1 quota unit"). LLM request / token billing should still be done at the
433/// provider call layer; this helper is only relevant when the product itself
434/// counts user-initiated messages.
435pub fn is_billable_user_turn(message: &Message) -> bool {
436    matches!(message.role, Role::User) && !is_system_resume_message(message)
437}
438
439/// Count the number of billable user turns in a session — i.e. user messages
440/// that were actually initiated by the human, excluding runtime-injected
441/// resume messages from child completion, retry, or conclusion-with-options.
442pub 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        // Mirror the GET /history filter so the client (which only ever sees
453        // visible messages) and the server agree on message_count /
454        // last_message_id. Hidden runtime resume messages stay internal and
455        // do not leak into the sync protocol.
456        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        // Add a user message so has_pending_user_message is true
482        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    // ---- normalize_model ----
506
507    #[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    // ---- resolve_model_cascade (flag OFF) ----
541
542    #[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    // ---- resolve_model_ref_cascade (flag ON) ----
601
602    #[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    // ---- evaluate_client_sync ----
714
715    #[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    // ---- has_pending_user_message ----
848
849    #[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    // ---- has_pending_conclusion_with_options_resume / has_pending_retry_resume ----
871
872    #[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    // ---- consume_pending_conclusion_with_options_resume ----
914
915    #[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    // ---- ServerExecuteSnapshot::from_session ----
939
940    #[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        // Mirror the runtime resume injection: visible user, assistant reply,
972        // then a hidden_from_ui synthetic user message. The client only ever
973        // sees the first two via /history, so the snapshot must agree.
974        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        // Raw last is a (hidden) user message, so the runtime still has work
993        // to do — the snapshot must keep this true so resume can proceed.
994        assert!(snapshot.has_pending_user_message);
995    }
996
997    #[test]
998    fn snapshot_excludes_trailing_hidden_message_for_last_id() {
999        // If the trailing message is hidden, last_message_id must come from
1000        // the last visible message — otherwise the client (which never saw
1001        // the hidden id) will report LastMessageIdMismatch on every execute.
1002        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        // Regression for the "Execute remains out-of-sync after 2 recovery
1022        // attempt(s)" loop: server has 1 hidden runtime message appended
1023        // after the visible turn, the client only sees the visible turn,
1024        // and execute used to return MessageCountMismatch forever.
1025        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        // The bug this whole change fixes was a *consistency* failure: the
1049        // /history endpoint and the execute sync snapshot were using
1050        // different rules to decide which messages are visible to the
1051        // client. Lock that rule in by computing the visible view both
1052        // ways from the same session and asserting they agree on
1053        // count and last id.
1054        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        // Reproduce the /history filter using the same shared helper.
1076        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    // ---- billing helpers ----
1091
1092    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}