1use 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#[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 pub fn new(dsl: Arc<HandleDslAuthority>) -> Self {
58 Self {
59 dsl,
60 visibility_owner: None,
61 }
62 }
63
64 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 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 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 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}