Skip to main content

meerkat_runtime/handles/
model_routing.rs

1//! Runtime impl of [`meerkat_core::handles::ModelRoutingHandle`].
2
3use std::sync::Arc;
4
5use meerkat_core::handles::{
6    DslTransitionError, ModelRoutingHandle, StickyModelFallbackMachineCommit,
7    StickyModelFallbackVisibilityPlan,
8};
9use meerkat_core::lifecycle::run_primitive::ModelId;
10use meerkat_core::model_profile::ModelProfile;
11use meerkat_core::{
12    ModelProfileWitness, SessionLlmIdentity, StickyModelFallbackActivationProof, ToolFilter,
13};
14
15use super::HandleDslAuthority;
16use crate::meerkat_machine::dsl as mm_dsl;
17
18fn capability_surface_from_profile(profile: &ModelProfile) -> mm_dsl::SessionLlmCapabilitySurface {
19    mm_dsl::SessionLlmCapabilitySurface {
20        supports_temperature: profile.supports_temperature,
21        supports_thinking: profile.supports_thinking,
22        supports_reasoning: profile.supports_reasoning,
23        inline_video: profile.inline_video,
24        vision: profile.vision,
25        image_input: profile.image_input,
26        image_tool_results: profile.image_tool_results,
27        supports_web_search: profile.supports_web_search,
28        image_generation: profile.image_generation,
29        realtime: profile.realtime,
30        call_timeout_secs: profile.call_timeout_secs,
31    }
32}
33
34/// Runtime-backed [`ModelRoutingHandle`] impl.
35#[derive(Debug)]
36pub struct RuntimeModelRoutingHandle {
37    dsl: Arc<HandleDslAuthority>,
38    visibility_owner: Option<Arc<crate::meerkat_machine::MachineToolVisibilityOwner>>,
39}
40
41struct RuntimeStickyModelFallbackMachineCommit {
42    dsl: Arc<HandleDslAuthority>,
43    visibility_owner: Arc<crate::meerkat_machine::MachineToolVisibilityOwner>,
44    expected_authority: mm_dsl::MeerkatMachineAuthoritySnapshot,
45    input: mm_dsl::MeerkatMachineInput,
46}
47
48impl StickyModelFallbackMachineCommit for RuntimeStickyModelFallbackMachineCommit {
49    fn commit(self: Box<Self>) -> Result<(), DslTransitionError> {
50        self.visibility_owner
51            .commit_previewed_sticky_model_fallback(&self.dsl, &self.expected_authority, self.input)
52    }
53}
54
55impl RuntimeModelRoutingHandle {
56    /// Construct a handle backed by the session's shared DSL authority.
57    pub fn new(dsl: Arc<HandleDslAuthority>) -> Self {
58        Self {
59            dsl,
60            visibility_owner: None,
61        }
62    }
63
64    /// Construct the session-owned handle with the canonical visibility
65    /// projection that shares this DSL authority.
66    pub(crate) fn new_with_visibility_owner(
67        dsl: Arc<HandleDslAuthority>,
68        visibility_owner: Arc<crate::meerkat_machine::MachineToolVisibilityOwner>,
69    ) -> Self {
70        Self {
71            dsl,
72            visibility_owner: Some(visibility_owner),
73        }
74    }
75
76    /// Construct a handle backed by an ephemeral DSL authority.
77    pub fn ephemeral() -> Self {
78        Self::new(Arc::new(HandleDslAuthority::ephemeral()))
79    }
80
81    fn stage_authorized_sticky_model_fallback(
82        &self,
83        previous_identity: &SessionLlmIdentity,
84        target_identity: &SessionLlmIdentity,
85        target_profile: &ModelProfileWitness,
86        visibility_plan: &StickyModelFallbackVisibilityPlan,
87        retry_attempt: u32,
88    ) -> Result<Box<dyn StickyModelFallbackMachineCommit>, DslTransitionError> {
89        if !target_profile.matches_identity(target_identity) {
90            return Err(DslTransitionError::guard_rejected(
91                "ModelRoutingHandle::stage_sticky_model_fallback",
92                "fallback profile witness must belong to the exact target identity",
93            ));
94        }
95        let expected_filter = meerkat_core::capability_base_filter_for_image_tool_results(
96            target_profile.profile().image_tool_results,
97        );
98        if visibility_plan.next_state.capability_base_filter != expected_filter {
99            return Err(DslTransitionError::guard_rejected(
100                "ModelRoutingHandle::stage_sticky_model_fallback",
101                "fallback visibility plan does not match the core-authorized target profile",
102            ));
103        }
104        let target_profile_provider = target_profile.provider();
105        let target_profile_model = target_profile.model();
106        let target_profile = target_profile.profile();
107        if target_profile_provider != target_profile.provider
108            || target_profile_model != target_identity.model
109        {
110            return Err(DslTransitionError::guard_rejected(
111                "ModelRoutingHandle::stage_sticky_model_fallback",
112                "fallback capability profile and exact provider/model provenance must be paired",
113            ));
114        }
115        let input = mm_dsl::MeerkatMachineInput::CommitStickyModelFallback {
116            previous_identity: mm_dsl::SessionLlmIdentity::from_domain(previous_identity),
117            previous_visibility_state: mm_dsl::SessionToolVisibilityState::from_domain(
118                &visibility_plan.previous_state,
119            ),
120            target_identity: mm_dsl::SessionLlmIdentity::from_domain(target_identity),
121            target_model: target_identity.model.clone(),
122            target_profile_provider: Some(target_profile_provider.into()),
123            target_profile_model: Some(target_profile_model.to_string()),
124            target_capability_surface: Some(capability_surface_from_profile(target_profile)),
125            target_capability_surface_status: mm_dsl::SessionLlmCapabilitySurfaceStatus::Resolved,
126            target_capability_base_filter: mm_dsl::ToolFilter::from_domain(
127                &visibility_plan.next_state.capability_base_filter,
128            ),
129            target_realtime_capable: target_profile.realtime,
130            view_image_tool_available: visibility_plan.view_image_tool_available,
131            previous_view_image_visible: visibility_plan.previous_view_image_visible,
132            next_view_image_visible: visibility_plan.next_view_image_visible,
133            previous_active_visibility_revision: visibility_plan.previous_state.active_revision,
134            previous_staged_visibility_revision: visibility_plan.previous_state.staged_revision,
135            next_visibility_state: mm_dsl::SessionToolVisibilityState::from_domain(
136                &visibility_plan.next_state,
137            ),
138            next_active_visibility_revision: visibility_plan.next_state.active_revision,
139            tool_visibility_delta: mm_dsl::SessionToolVisibilityDelta {
140                previous_capability_base_filter: mm_dsl::ToolFilter::from_domain(
141                    &visibility_plan.previous_state.capability_base_filter,
142                ),
143                current_capability_base_filter: mm_dsl::ToolFilter::from_domain(
144                    &visibility_plan.next_state.capability_base_filter,
145                ),
146                committed_visible_set_changed: visibility_plan.committed_visible_set_changed,
147                revision_bumped: visibility_plan.revision_bumped,
148            },
149            retry_attempt: u64::from(retry_attempt),
150        };
151        let visibility_owner = self.visibility_owner.as_ref().ok_or_else(|| {
152            DslTransitionError::no_matching(
153                "ModelRoutingHandle::stage_sticky_model_fallback",
154                "session-owned model routing handle is missing its canonical visibility owner",
155            )
156        })?;
157        let expected_authority = self
158            .dsl
159            .preview_input(&input, "ModelRoutingHandle::stage_sticky_model_fallback")?;
160        Ok(Box::new(RuntimeStickyModelFallbackMachineCommit {
161            dsl: Arc::clone(&self.dsl),
162            visibility_owner: Arc::clone(visibility_owner),
163            expected_authority,
164            input,
165        }))
166    }
167
168    #[cfg(test)]
169    pub(crate) fn commit_sticky_model_fallback_for_test(
170        &self,
171        previous_identity: &SessionLlmIdentity,
172        target_identity: &SessionLlmIdentity,
173        target_profile: &ModelProfileWitness,
174        visibility_plan: &StickyModelFallbackVisibilityPlan,
175        retry_attempt: u32,
176    ) -> Result<(), DslTransitionError> {
177        self.stage_authorized_sticky_model_fallback(
178            previous_identity,
179            target_identity,
180            target_profile,
181            visibility_plan,
182            retry_attempt,
183        )?
184        .commit()
185    }
186}
187
188impl ModelRoutingHandle for RuntimeModelRoutingHandle {
189    fn set_baseline(
190        &self,
191        baseline_model: ModelId,
192        realtime_capable: bool,
193    ) -> Result<(), DslTransitionError> {
194        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
195        self.dsl.apply_input(
196            mm_dsl::MeerkatMachineInput::SetModelRoutingBaseline {
197                baseline_model: baseline_model.to_string(),
198                realtime_capable,
199            },
200            "ModelRoutingHandle::set_baseline",
201        )
202    }
203
204    fn hydrate_llm_capability_surface(
205        &self,
206        identity: &SessionLlmIdentity,
207        profile: Option<&ModelProfile>,
208        capability_base_filter: &ToolFilter,
209    ) -> Result<(), DslTransitionError> {
210        let (current_capability_surface, current_capability_surface_status) = match profile {
211            Some(profile) => (
212                Some(capability_surface_from_profile(profile)),
213                mm_dsl::SessionLlmCapabilitySurfaceStatus::Resolved,
214            ),
215            None => (None, mm_dsl::SessionLlmCapabilitySurfaceStatus::Unresolved),
216        };
217        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
218        self.dsl.apply_input(
219            mm_dsl::MeerkatMachineInput::HydrateSessionLlmState {
220                current_identity: mm_dsl::SessionLlmIdentity::from_domain(identity),
221                current_capability_surface,
222                current_capability_surface_status,
223                current_capability_base_filter: mm_dsl::ToolFilter::from_domain(
224                    capability_base_filter,
225                ),
226            },
227            "ModelRoutingHandle::hydrate_llm_capability_surface",
228        )
229    }
230
231    fn stage_sticky_model_fallback(
232        &self,
233        activation: StickyModelFallbackActivationProof,
234        visibility_plan: &StickyModelFallbackVisibilityPlan,
235    ) -> Result<Box<dyn StickyModelFallbackMachineCommit>, DslTransitionError> {
236        if activation.target_capability_base_filter()
237            != &visibility_plan.next_state.capability_base_filter
238        {
239            return Err(DslTransitionError::guard_rejected(
240                "ModelRoutingHandle::stage_sticky_model_fallback",
241                "fallback visibility plan does not match the sealed activation capability",
242            ));
243        }
244        self.stage_authorized_sticky_model_fallback(
245            activation.previous_identity(),
246            activation.target_identity(),
247            activation.target_profile(),
248            visibility_plan,
249            activation.retry_attempt(),
250        )
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use meerkat_core::ToolVisibilityOwner as _;
258
259    fn identity(model: &str, provider: meerkat_core::Provider) -> SessionLlmIdentity {
260        SessionLlmIdentity {
261            model: model.to_string(),
262            provider,
263            self_hosted_server_id: None,
264            provider_params: None,
265            auth_binding: None,
266        }
267    }
268
269    fn empty_model_catalog() -> meerkat_core::ModelCatalog {
270        meerkat_core::ModelCatalog {
271            entries: &[],
272            capabilities: &[],
273            provider_defaults: &[],
274            image_generation_models: &[],
275            providers: &[],
276            default_models: &[],
277            image_generation_defaults: &[],
278            global_default_model: "",
279            provider_priority: &[],
280        }
281    }
282
283    fn custom_profile_witness(
284        model: &str,
285        provider: meerkat_core::Provider,
286        vision: bool,
287    ) -> ModelProfileWitness {
288        let mut config = meerkat_core::Config::default();
289        config.models.custom.insert(
290            model.to_string(),
291            meerkat_core::config::CustomModelConfig {
292                provider,
293                display_name: Some(format!("Test {model}")),
294                context_window: Some(128_000),
295                max_output_tokens: Some(4096),
296                vision: Some(vision),
297                web_search: Some(false),
298                call_timeout_secs: None,
299            },
300        );
301        meerkat_core::ModelRegistry::from_config(&config, empty_model_catalog())
302            .expect("custom-model registry")
303            .profile_witness_for_provider(provider, model)
304            .expect("custom-model profile witness")
305    }
306
307    #[test]
308    fn sticky_fallback_requires_matching_machine_recovery_and_replaces_routing_truth() {
309        let mut authority = mm_dsl::MeerkatMachineAuthority::new();
310        authority
311            .apply_signal(mm_dsl::MeerkatMachineSignal::Initialize)
312            .expect("initialize authority");
313        mm_dsl::MeerkatMachineMutator::apply(
314            &mut authority,
315            mm_dsl::MeerkatMachineInput::RegisterSession {
316                session_id: mm_dsl::SessionId("session-fallback".to_string()),
317            },
318        )
319        .expect("register session");
320
321        let shared = Arc::new(std::sync::Mutex::new(authority));
322        let visibility_owner = Arc::new(crate::meerkat_machine::MachineToolVisibilityOwner::new());
323        visibility_owner.bind_dsl_authority(Arc::clone(&shared));
324        let handle = RuntimeModelRoutingHandle::new_with_visibility_owner(
325            Arc::new(HandleDslAuthority::from_shared(Arc::clone(&shared))),
326            Arc::clone(&visibility_owner),
327        );
328        let previous = identity("claude-primary", meerkat_core::Provider::Anthropic);
329        let target = identity("gpt-backup", meerkat_core::Provider::OpenAI);
330        let target_profile =
331            custom_profile_witness("gpt-backup", meerkat_core::Provider::OpenAI, false);
332        let mismatched_profile = custom_profile_witness(
333            "gpt-different-backup",
334            meerkat_core::Provider::OpenAI,
335            false,
336        );
337        let target_filter = ToolFilter::Deny(
338            [meerkat_core::VIEW_IMAGE_TOOL_NAME.to_string()]
339                .into_iter()
340                .collect(),
341        );
342        let mut next_visibility_state = meerkat_core::SessionToolVisibilityState {
343            capability_base_filter: target_filter.clone(),
344            ..Default::default()
345        };
346        next_visibility_state.active_revision = 1;
347        next_visibility_state.staged_revision = 1;
348        let visibility_plan = StickyModelFallbackVisibilityPlan {
349            previous_state: Default::default(),
350            next_state: next_visibility_state.clone(),
351            view_image_tool_available: true,
352            previous_view_image_visible: true,
353            next_view_image_visible: false,
354            committed_visible_set_changed: true,
355            revision_bumped: true,
356        };
357        handle
358            .hydrate_llm_capability_surface(&previous, None, &ToolFilter::All)
359            .expect("hydrate initial identity");
360
361        {
362            let mut authority = shared
363                .lock()
364                .unwrap_or_else(std::sync::PoisonError::into_inner);
365            let run_id = mm_dsl::RunId("run-fallback".to_string());
366            for input in [
367                mm_dsl::MeerkatMachineInput::Prepare {
368                    session_id: mm_dsl::SessionId("session-fallback".to_string()),
369                    run_id: run_id.clone(),
370                },
371                mm_dsl::MeerkatMachineInput::StartConversationRun {
372                    run_id: run_id.clone(),
373                    primitive_kind: mm_dsl::TurnPrimitiveKind::ConversationTurn,
374                    admitted_content_shape: mm_dsl::ContentShape::Conversation,
375                    vision_enabled: false,
376                    image_tool_results_enabled: false,
377                    max_extraction_retries: 0,
378                },
379                mm_dsl::MeerkatMachineInput::PrimitiveApplied {
380                    run_id: run_id.clone(),
381                },
382                mm_dsl::MeerkatMachineInput::RecoverableFailure {
383                    run_id,
384                    failure_kind: mm_dsl::LlmRetryFailureKind::RetryableProviderError,
385                    retry_attempt: 1,
386                    max_retries: 1,
387                    selected_delay_ms: 0,
388                    error: "provider overloaded".to_string(),
389                },
390            ] {
391                mm_dsl::MeerkatMachineMutator::apply(&mut *authority, input)
392                    .expect("drive fallback recovery precondition");
393            }
394        }
395
396        let mismatched_profile_provenance = handle
397            .commit_sticky_model_fallback_for_test(
398                &previous,
399                &target,
400                &mismatched_profile,
401                &visibility_plan,
402                1,
403            )
404            .expect_err("profile provenance for a different model must fail closed");
405        assert_eq!(
406            mismatched_profile_provenance.kind,
407            meerkat_core::handles::DslRejectionKind::GuardRejected
408        );
409        {
410            let authority = shared
411                .lock()
412                .unwrap_or_else(std::sync::PoisonError::into_inner);
413            assert_eq!(
414                authority.state().current_session_llm_identity,
415                Some(mm_dsl::SessionLlmIdentity::from_domain(&previous)),
416                "rejected profile provenance must not mutate routing identity"
417            );
418        }
419
420        let wrong_attempt = handle
421            .commit_sticky_model_fallback_for_test(
422                &previous,
423                &target,
424                &target_profile,
425                &visibility_plan,
426                2,
427            )
428            .expect_err("wrong retry attempt must fail closed");
429        assert_eq!(
430            wrong_attempt.kind,
431            meerkat_core::handles::DslRejectionKind::GuardRejected
432        );
433        {
434            let authority = shared
435                .lock()
436                .unwrap_or_else(std::sync::PoisonError::into_inner);
437            assert_eq!(
438                authority.state().current_session_llm_identity,
439                Some(mm_dsl::SessionLlmIdentity::from_domain(&previous)),
440                "a rejected fallback attempt must not mutate routing identity"
441            );
442        }
443
444        handle
445            .commit_sticky_model_fallback_for_test(
446                &previous,
447                &target,
448                &target_profile,
449                &visibility_plan,
450                1,
451            )
452            .expect("matching machine recovery should authorize fallback");
453
454        let authority = shared
455            .lock()
456            .unwrap_or_else(std::sync::PoisonError::into_inner);
457        let state = authority.state();
458        assert_eq!(
459            state.current_session_llm_identity,
460            Some(mm_dsl::SessionLlmIdentity::from_domain(&target))
461        );
462        assert_eq!(
463            state.model_routing_baseline_model.as_deref(),
464            Some("gpt-backup")
465        );
466        assert_eq!(state.model_routing_baseline_realtime, Some(false));
467        assert_eq!(state.model_routing_topology_epoch, 1);
468        assert_eq!(state.active_visibility_revision, 1);
469        assert_eq!(
470            state.current_session_capability_base_filter,
471            mm_dsl::ToolFilter::from_domain(&target_filter)
472        );
473        drop(authority);
474        assert_eq!(
475            visibility_owner.visibility_state().unwrap(),
476            next_visibility_state,
477            "the machine-owned visibility projection must mirror the same accepted transition"
478        );
479        let after_boundary = visibility_owner
480            .boundary_applied()
481            .expect("normal boundary after fallback must remain monotonic");
482        assert_eq!(after_boundary.active_revision, 1);
483        assert_eq!(after_boundary.staged_revision, 1);
484    }
485
486    #[test]
487    fn standalone_authorities_share_turn_routing_and_visibility_for_fallback() {
488        use meerkat_core::TurnStateHandle as _;
489
490        let session_id = meerkat_core::SessionId::new();
491        let previous = identity("claude-primary", meerkat_core::Provider::Anthropic);
492        let target = identity("gpt-backup", meerkat_core::Provider::OpenAI);
493        let authorities = crate::standalone_session_runtime_authorities(
494            &session_id,
495            &previous,
496            None,
497            &ToolFilter::All,
498        )
499        .expect("standalone authority bundle");
500
501        let run_id = meerkat_core::lifecycle::RunId::new();
502        authorities
503            .turn_state()
504            .start_conversation_run(
505                run_id.clone(),
506                meerkat_core::turn_execution_authority::TurnPrimitiveKind::ConversationTurn,
507                meerkat_core::turn_execution_authority::ContentShape::Conversation,
508                false,
509                false,
510                0,
511            )
512            .expect("standalone shared authority should admit the turn");
513        authorities
514            .turn_state()
515            .primitive_applied(run_id.clone())
516            .expect("primitive applied");
517        authorities
518            .turn_state()
519            .recoverable_failure(
520                run_id,
521                meerkat_core::LlmRetrySchedule {
522                    failure: meerkat_core::LlmRetryFailure {
523                        provider: "anthropic".to_string(),
524                        kind: meerkat_core::LlmRetryFailureKind::RetryableProviderError,
525                        retry_after_ms: None,
526                        duration_ms: None,
527                        message: "overloaded".to_string(),
528                    },
529                    plan: meerkat_core::LlmRetryPlan {
530                        attempt: 1,
531                        max_retries: 1,
532                        computed_delay_ms: 0,
533                        selected_delay_ms: 0,
534                        retry_after_hint_ms: None,
535                        rate_limit_floor_applied: false,
536                        budget_capped: false,
537                    },
538                },
539            )
540            .expect("recoverable failure");
541
542        let target_filter = ToolFilter::Deny(
543            [meerkat_core::VIEW_IMAGE_TOOL_NAME.to_string()]
544                .into_iter()
545                .collect(),
546        );
547        let mut next_state = meerkat_core::SessionToolVisibilityState {
548            capability_base_filter: target_filter,
549            ..Default::default()
550        };
551        next_state.active_revision = 1;
552        next_state.staged_revision = 1;
553        let plan = StickyModelFallbackVisibilityPlan {
554            previous_state: Default::default(),
555            next_state: next_state.clone(),
556            view_image_tool_available: true,
557            previous_view_image_visible: true,
558            next_view_image_visible: false,
559            committed_visible_set_changed: true,
560            revision_bumped: true,
561        };
562        let target_profile =
563            custom_profile_witness("gpt-backup", meerkat_core::Provider::OpenAI, false);
564        authorities
565            .commit_sticky_model_fallback_for_test(&previous, &target, &target_profile, &plan, 1)
566            .expect("standalone sticky fallback must use the shared turn authority");
567
568        assert_eq!(
569            authorities
570                .tool_visibility_owner()
571                .visibility_state()
572                .unwrap(),
573            next_state,
574            "standalone fallback must mirror the accepted visibility state"
575        );
576    }
577
578    #[test]
579    fn sticky_fallback_rebases_pending_staged_visibility_before_next_boundary() {
580        use meerkat_core::{ToolVisibilityOwner as _, TurnStateHandle as _};
581
582        let session_id = meerkat_core::SessionId::new();
583        let previous = identity("claude-primary", meerkat_core::Provider::Anthropic);
584        let target = identity("gpt-backup", meerkat_core::Provider::OpenAI);
585        let authorities = crate::standalone_session_runtime_authorities(
586            &session_id,
587            &previous,
588            None,
589            &ToolFilter::All,
590        )
591        .expect("standalone authority bundle");
592        let witness = meerkat_core::ToolVisibilityWitness {
593            last_seen_provenance: Some(meerkat_core::ToolProvenance {
594                kind: meerkat_core::ToolSourceKind::Builtin,
595                source_id: meerkat_core::ToolSourceId::new("builtin:view_image"),
596            }),
597        };
598        let view_image = meerkat_core::ToolName::from(meerkat_core::VIEW_IMAGE_TOOL_NAME);
599        let witness_catalog = [(view_image.clone(), witness.clone())]
600            .into_iter()
601            .collect();
602        authorities
603            .tool_visibility_owner()
604            .replace_filter_tool_authority_catalog(witness_catalog)
605            .expect("install filter witness catalog");
606        let pending_filter = ToolFilter::Deny([view_image.clone()].into_iter().collect());
607        let pending_revision = authorities
608            .tool_visibility_owner()
609            .stage_persistent_filter(
610                pending_filter.clone(),
611                [(view_image, witness)].into_iter().collect(),
612            )
613            .expect("stage pending visibility filter");
614        assert_eq!(pending_revision, meerkat_core::ToolScopeRevision(1));
615        let previous_visibility = authorities
616            .tool_visibility_owner()
617            .visibility_state()
618            .expect("pending visibility state");
619        assert_eq!(previous_visibility.active_revision, 0);
620        assert_eq!(previous_visibility.staged_revision, 1);
621
622        let run_id = meerkat_core::lifecycle::RunId::new();
623        authorities
624            .turn_state()
625            .start_conversation_run(
626                run_id.clone(),
627                meerkat_core::turn_execution_authority::TurnPrimitiveKind::ConversationTurn,
628                meerkat_core::turn_execution_authority::ContentShape::Conversation,
629                false,
630                false,
631                0,
632            )
633            .expect("admit turn");
634        authorities
635            .turn_state()
636            .primitive_applied(run_id.clone())
637            .expect("primitive applied");
638        authorities
639            .turn_state()
640            .recoverable_failure(
641                run_id,
642                meerkat_core::LlmRetrySchedule {
643                    failure: meerkat_core::LlmRetryFailure {
644                        provider: "anthropic".to_string(),
645                        kind: meerkat_core::LlmRetryFailureKind::RetryableProviderError,
646                        retry_after_ms: None,
647                        duration_ms: None,
648                        message: "overloaded".to_string(),
649                    },
650                    plan: meerkat_core::LlmRetryPlan {
651                        attempt: 1,
652                        max_retries: 1,
653                        computed_delay_ms: 0,
654                        selected_delay_ms: 0,
655                        retry_after_hint_ms: None,
656                        rate_limit_floor_applied: false,
657                        budget_capped: false,
658                    },
659                },
660            )
661            .expect("recoverable failure");
662
663        let target_filter = ToolFilter::Deny(
664            [meerkat_core::VIEW_IMAGE_TOOL_NAME.to_string()]
665                .into_iter()
666                .collect(),
667        );
668        let mut next_visibility = previous_visibility.clone();
669        next_visibility.capability_base_filter = target_filter;
670        next_visibility.active_revision = 2;
671        next_visibility.staged_revision = 3;
672        let plan = StickyModelFallbackVisibilityPlan {
673            previous_state: previous_visibility,
674            next_state: next_visibility.clone(),
675            view_image_tool_available: true,
676            previous_view_image_visible: true,
677            next_view_image_visible: false,
678            committed_visible_set_changed: true,
679            revision_bumped: true,
680        };
681        let target_profile =
682            custom_profile_witness("gpt-backup", meerkat_core::Provider::OpenAI, false);
683        authorities
684            .commit_sticky_model_fallback_for_test(&previous, &target, &target_profile, &plan, 1)
685            .expect("fallback must rebase the pending staged revision");
686        assert_eq!(
687            authorities
688                .tool_visibility_owner()
689                .visibility_state()
690                .unwrap(),
691            next_visibility
692        );
693
694        let after_boundary = authorities
695            .tool_visibility_owner()
696            .boundary_applied()
697            .expect("pending staged state must promote monotonically after fallback");
698        assert_eq!(after_boundary.active_revision, 3);
699        assert_eq!(after_boundary.staged_revision, 3);
700        assert_eq!(after_boundary.active_filter, pending_filter);
701    }
702
703    #[derive(Clone)]
704    struct PostFallbackReconfigureHost {
705        identity: Arc<std::sync::Mutex<SessionLlmIdentity>>,
706        visibility: Arc<std::sync::Mutex<meerkat_core::SessionToolVisibilityState>>,
707        current_surface: crate::SessionLlmCapabilitySurface,
708        target_identity: SessionLlmIdentity,
709        target_surface: crate::SessionLlmCapabilitySurface,
710    }
711
712    #[async_trait::async_trait]
713    impl crate::SessionLlmReconfigureHost for PostFallbackReconfigureHost {
714        async fn hydrate_session_llm_state(
715            &self,
716            _session_id: &meerkat_core::SessionId,
717        ) -> Result<crate::HydratedSessionLlmState, crate::RuntimeDriverError> {
718            Ok(crate::HydratedSessionLlmState {
719                current_identity: self
720                    .identity
721                    .lock()
722                    .unwrap_or_else(std::sync::PoisonError::into_inner)
723                    .clone(),
724                current_visibility_state: self
725                    .visibility
726                    .lock()
727                    .unwrap_or_else(std::sync::PoisonError::into_inner)
728                    .clone(),
729                current_capability_surface: Some(self.current_surface.clone()),
730                capability_surface_status: crate::SessionLlmCapabilitySurfaceStatus::Resolved,
731                base_tool_names: [meerkat_core::ToolName::from(
732                    meerkat_core::VIEW_IMAGE_TOOL_NAME,
733                )]
734                .into_iter()
735                .collect(),
736            })
737        }
738
739        async fn resolve_target_session_llm_identity(
740            &self,
741            _request: &crate::SessionLlmReconfigureRequest,
742            _current_identity: &SessionLlmIdentity,
743        ) -> Result<crate::ResolvedSessionLlmReconfigure, crate::RuntimeDriverError> {
744            Ok(crate::ResolvedSessionLlmReconfigure {
745                target_identity: self.target_identity.clone(),
746                target_capability_surface: self.target_surface.clone(),
747            })
748        }
749
750        async fn apply_live_session_llm_identity(
751            &self,
752            _session_id: &meerkat_core::SessionId,
753            identity: &SessionLlmIdentity,
754        ) -> Result<(), crate::RuntimeDriverError> {
755            *self
756                .identity
757                .lock()
758                .unwrap_or_else(std::sync::PoisonError::into_inner) = identity.clone();
759            Ok(())
760        }
761
762        async fn apply_live_session_tool_visibility_state(
763            &self,
764            _session_id: &meerkat_core::SessionId,
765            visibility: Option<meerkat_core::SessionToolVisibilityState>,
766        ) -> Result<(), crate::RuntimeDriverError> {
767            *self
768                .visibility
769                .lock()
770                .unwrap_or_else(std::sync::PoisonError::into_inner) =
771                visibility.unwrap_or_default();
772            Ok(())
773        }
774
775        async fn persist_live_session(
776            &self,
777            _session_id: &meerkat_core::SessionId,
778        ) -> Result<(), crate::RuntimeDriverError> {
779            Ok(())
780        }
781
782        async fn discard_live_session(
783            &self,
784            _session_id: &meerkat_core::SessionId,
785        ) -> Result<(), crate::RuntimeDriverError> {
786            Ok(())
787        }
788    }
789
790    fn runtime_capability_surface(image_tool_results: bool) -> crate::SessionLlmCapabilitySurface {
791        crate::SessionLlmCapabilitySurface {
792            supports_temperature: true,
793            supports_thinking: false,
794            supports_reasoning: false,
795            inline_video: false,
796            vision: image_tool_results,
797            image_input: image_tool_results,
798            image_tool_results,
799            supports_web_search: false,
800            image_generation: false,
801            realtime: false,
802            call_timeout_secs: None,
803        }
804    }
805
806    #[tokio::test]
807    async fn prepared_bindings_fallback_projects_status_visibility_and_allows_reconfigure() {
808        use crate::SessionServiceRuntimeExt as _;
809
810        let machine = Arc::new(crate::MeerkatMachine::ephemeral());
811        let session_id = meerkat_core::SessionId::new();
812        let bindings = machine
813            .prepare_bindings(session_id.clone())
814            .await
815            .expect("production bindings");
816        let model_routing = machine
817            .model_routing_handle_for_test(&session_id)
818            .await
819            .expect("concrete production routing handle");
820        let previous = identity("claude-primary", meerkat_core::Provider::Anthropic);
821        let target = identity("gpt-backup", meerkat_core::Provider::OpenAI);
822        let target_profile =
823            custom_profile_witness("gpt-backup", meerkat_core::Provider::OpenAI, false);
824        bindings
825            .model_routing()
826            .set_baseline(
827                meerkat_core::lifecycle::run_primitive::ModelId::new(previous.model.clone()),
828                false,
829            )
830            .expect("baseline");
831        bindings
832            .model_routing()
833            .hydrate_llm_capability_surface(&previous, None, &ToolFilter::All)
834            .expect("initial identity hydration");
835
836        let run_id = meerkat_core::lifecycle::RunId::new();
837        bindings
838            .session_admission()
839            .prepare(&run_id)
840            .expect("prepare run");
841        bindings
842            .turn_state()
843            .start_conversation_run(
844                run_id.clone(),
845                meerkat_core::turn_execution_authority::TurnPrimitiveKind::ConversationTurn,
846                meerkat_core::turn_execution_authority::ContentShape::Conversation,
847                false,
848                false,
849                0,
850            )
851            .expect("start turn");
852        bindings
853            .turn_state()
854            .primitive_applied(run_id.clone())
855            .expect("primitive applied");
856        bindings
857            .turn_state()
858            .recoverable_failure(
859                run_id,
860                meerkat_core::LlmRetrySchedule {
861                    failure: meerkat_core::LlmRetryFailure {
862                        provider: "anthropic".to_string(),
863                        kind: meerkat_core::LlmRetryFailureKind::RetryableProviderError,
864                        retry_after_ms: None,
865                        duration_ms: None,
866                        message: "overloaded".to_string(),
867                    },
868                    plan: meerkat_core::LlmRetryPlan {
869                        attempt: 1,
870                        max_retries: 1,
871                        computed_delay_ms: 0,
872                        selected_delay_ms: 0,
873                        retry_after_hint_ms: None,
874                        rate_limit_floor_applied: false,
875                        budget_capped: false,
876                    },
877                },
878            )
879            .expect("recoverable failure");
880
881        let target_filter = ToolFilter::Deny(
882            [meerkat_core::VIEW_IMAGE_TOOL_NAME.to_string()]
883                .into_iter()
884                .collect(),
885        );
886        let mut fallback_visibility = meerkat_core::SessionToolVisibilityState {
887            capability_base_filter: target_filter,
888            ..Default::default()
889        };
890        fallback_visibility.active_revision = 1;
891        fallback_visibility.staged_revision = 1;
892        model_routing
893            .commit_sticky_model_fallback_for_test(
894                &previous,
895                &target,
896                &target_profile,
897                &StickyModelFallbackVisibilityPlan {
898                    previous_state: Default::default(),
899                    next_state: fallback_visibility.clone(),
900                    view_image_tool_available: true,
901                    previous_view_image_visible: true,
902                    next_view_image_visible: false,
903                    committed_visible_set_changed: true,
904                    revision_bumped: true,
905                },
906                1,
907            )
908            .expect("fallback commit through production bindings");
909
910        let status = machine
911            .session_model_routing_status(&session_id)
912            .await
913            .expect("routing status");
914        assert_eq!(status.baseline_model.as_str(), "gpt-backup");
915        assert_eq!(status.effective_model.as_str(), "gpt-backup");
916        assert_eq!(
917            status.session_provider,
918            Some(meerkat_core::Provider::OpenAI)
919        );
920        assert_eq!(
921            bindings.tool_visibility_owner().visibility_state().unwrap(),
922            fallback_visibility,
923        );
924
925        let next_identity = identity("gemini-next", meerkat_core::Provider::Gemini);
926        let host_identity = Arc::new(std::sync::Mutex::new(target.clone()));
927        let host_visibility = Arc::new(std::sync::Mutex::new(fallback_visibility));
928        machine.set_session_llm_reconfigure_host(Arc::new(PostFallbackReconfigureHost {
929            identity: Arc::clone(&host_identity),
930            visibility: Arc::clone(&host_visibility),
931            current_surface: runtime_capability_surface(false),
932            target_identity: next_identity.clone(),
933            target_surface: runtime_capability_surface(true),
934        }));
935        let report = machine
936            .reconfigure_session_llm_identity(
937                &session_id,
938                crate::SessionLlmReconfigureRequest {
939                    model: Some(next_identity.model.clone()),
940                    provider: Some("gemini".to_string()),
941                    self_hosted_server_id: None,
942                    provider_params: None,
943                    auth_binding: None,
944                },
945            )
946            .await
947            .expect("fallback state must remain valid input to subsequent reconfigure");
948        assert_eq!(report.previous_identity, target);
949        assert_eq!(report.new_identity, next_identity);
950        assert!(report.tool_visibility_delta.committed_visible_set_changed);
951        let reconfigured_visibility = bindings.tool_visibility_owner().visibility_state().unwrap();
952        assert_eq!(
953            reconfigured_visibility.capability_base_filter,
954            ToolFilter::All
955        );
956        assert_eq!(reconfigured_visibility.active_revision, 2);
957    }
958}