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        return Ok(ExecutePreparationOutcome::SyncMismatch {
39            reason,
40            server_snapshot,
41        });
42    }
43
44    // ---- Resolve model cascade ----
45    // Flag ON (new): session.model_ref → request.model_ref → config.default_model_ref
46    // Flag OFF (old): session.model → config.default_model → request.model
47    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    // ---- Resolve reasoning effort cascade: session → request → provider default ----
60    let effective_reasoning_effort = session
61        .reasoning_effort
62        .or(input.request_reasoning_effort)
63        .or(config.default_reasoning_effort);
64
65    // ---- Reasoning source ----
66    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    // ---- Image fallback validation ----
77    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    // ---- Check for pending user message ----
84    if !server_snapshot.has_pending_user_message {
85        return Ok(ExecutePreparationOutcome::NoPendingMessage { server_snapshot });
86    }
87
88    // ---- Update session metadata ----
89    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    // ---- Skill mode ----
122    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 ----
134    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
146/// Old-path model resolution: session.model → config.default_model → request.model
147pub(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
171/// New-path model resolution: session.model_ref → request.model_ref → config.default_model_ref.
172pub(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
208// ---- Internal helpers ----
209
210fn 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
299/// Whether the session has resumable user work (pending tool response, retry, or last message is from user).
300pub 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
333/// Returns true if the message is flagged `metadata.hidden_from_ui == true`.
334///
335/// Such messages are runtime-injected (e.g. child-completion resume, retry
336/// resume, conclusion-with-options resume) and are filtered out of any
337/// client-facing view. Both `GET /history` and the execute sync snapshot
338/// must use this exact predicate so the client and server agree on the
339/// visible message_count and last_message_id.
340pub(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
349// ---------------------------------------------------------------------------
350// Billing helpers
351// ---------------------------------------------------------------------------
352//
353// Some upstream products bill on "user-initiated message turns" rather than on
354// LLM request count. With the runtime-level suspend/resume model used by the
355// SubAgent tool, the engine can append `Role::User` messages that are NOT
356// caused by the human user (e.g. child-completion resume, retry resume,
357// conclusion_with_options resume). These helpers let a billing layer count
358// only genuine user turns and skip system-injected resume messages.
359
360/// Returns true if the message was synthesized by the runtime to resume a
361/// suspended root session (child completion, retry, conclusion-with-options).
362///
363/// Such messages have one or both of the following stable markers:
364/// - `metadata.hidden_from_ui == true`
365/// - `metadata.runtime_kind` set to a known resume kind
366pub 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
392/// Returns true when the message represents a billable end-user turn.
393///
394/// Use this in any per-message billing accounting (e.g. "1 user message =
395/// 1 quota unit"). LLM request / token billing should still be done at the
396/// provider call layer; this helper is only relevant when the product itself
397/// counts user-initiated messages.
398pub fn is_billable_user_turn(message: &Message) -> bool {
399    matches!(message.role, Role::User) && !is_system_resume_message(message)
400}
401
402/// Count the number of billable user turns in a session — i.e. user messages
403/// that were actually initiated by the human, excluding runtime-injected
404/// resume messages from child completion, retry, or conclusion-with-options.
405pub 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        // Mirror the GET /history filter so the client (which only ever sees
416        // visible messages) and the server agree on message_count /
417        // last_message_id. Hidden runtime resume messages stay internal and
418        // do not leak into the sync protocol.
419        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        // Add a user message so has_pending_user_message is true
445        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    // ---- normalize_model ----
469
470    #[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    // ---- resolve_model_cascade (flag OFF) ----
504
505    #[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    // ---- resolve_model_ref_cascade (flag ON) ----
564
565    #[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    // ---- evaluate_client_sync ----
677
678    #[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    // ---- has_pending_user_message ----
811
812    #[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    // ---- has_pending_conclusion_with_options_resume / has_pending_retry_resume ----
834
835    #[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    // ---- consume_pending_conclusion_with_options_resume ----
877
878    #[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    // ---- ServerExecuteSnapshot::from_session ----
902
903    #[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        // Mirror the runtime resume injection: visible user, assistant reply,
935        // then a hidden_from_ui synthetic user message. The client only ever
936        // sees the first two via /history, so the snapshot must agree.
937        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        // Raw last is a (hidden) user message, so the runtime still has work
956        // to do — the snapshot must keep this true so resume can proceed.
957        assert!(snapshot.has_pending_user_message);
958    }
959
960    #[test]
961    fn snapshot_excludes_trailing_hidden_message_for_last_id() {
962        // If the trailing message is hidden, last_message_id must come from
963        // the last visible message — otherwise the client (which never saw
964        // the hidden id) will report LastMessageIdMismatch on every execute.
965        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        // Regression for the "Execute remains out-of-sync after 2 recovery
985        // attempt(s)" loop: server has 1 hidden runtime message appended
986        // after the visible turn, the client only sees the visible turn,
987        // and execute used to return MessageCountMismatch forever.
988        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        // The bug this whole change fixes was a *consistency* failure: the
1012        // /history endpoint and the execute sync snapshot were using
1013        // different rules to decide which messages are visible to the
1014        // client. Lock that rule in by computing the visible view both
1015        // ways from the same session and asserting they agree on
1016        // count and last id.
1017        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        // Reproduce the /history filter using the same shared helper.
1039        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    // ---- billing helpers ----
1054
1055    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}