1use std::collections::HashMap;
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use parking_lot::Mutex;
14
15use super::fall_through::FallThrough;
16use super::llm_class::{
17 CustomClassifierConfig, LlmClassifierConfig, LlmTaskClassifier, TaskClassifierConfig,
18};
19use super::stage::{StageRouterConfig, build_stage_route};
20use super::util::affinity::{ClassifyTrigger, evict_if_full, has_new_user_turn, retention_key};
21use super::util::stage::{PickerMode, Tier, fall_open_tier, set_fall_open};
22use crate::core::algorithm::{Algorithm, Driver, RoutingIdentity};
23use crate::core::classifier::{Classification, Classifier};
24use crate::core::processor::{Event, Processor};
25use crate::core::state::State;
26use crate::{LibsyError, Result};
27use switchyard_protocol::{Category, Request, Response};
28
29const COMPOSITE: &str = "composite";
30
31struct TierSetter {
37 judge: Arc<dyn Classifier>,
38 trigger: ClassifyTrigger,
39 message_hash_fallback: bool,
40 default_tier: Tier,
41 tiers: Mutex<HashMap<RoutingIdentity, Tier>>,
42}
43
44impl TierSetter {
45 fn tier_for(category: Option<Category>) -> Option<Tier> {
46 match category {
47 Some(Category::Capable) => Some(Tier::Capable),
48 Some(Category::Efficient) => Some(Tier::Efficient),
49 _ => None,
50 }
51 }
52
53 fn is_due(&self, identity: Option<&RoutingIdentity>, state: &State, request: &Request) -> bool {
56 match self.trigger {
57 ClassifyTrigger::UserTurn => has_new_user_turn(&request.llm_request.messages),
58 ClassifyTrigger::NewSession => {
61 identity.is_none_or(|identity| !self.tiers.lock().contains_key(identity))
62 && fall_open_tier(state).is_none()
63 }
64 ClassifyTrigger::EveryRequest => true,
66 }
67 }
68
69 fn retain(&self, identity: RoutingIdentity, tier: Tier) {
70 let mut tiers = self.tiers.lock();
71 let writable = self.trigger == ClassifyTrigger::UserTurn || !tiers.contains_key(&identity);
74 if writable {
75 evict_if_full(&mut tiers);
76 tiers.insert(identity, tier);
77 }
78 }
79}
80
81#[async_trait]
82impl Processor for TierSetter {
83 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
84 let Event::Request { request, driver } = event else {
85 return Ok(());
86 };
87 let identity = retention_key(request, self.message_hash_fallback);
88 if self.is_due(identity.as_ref(), state, request) {
89 let (classification, _) = self.judge.score(state, request, driver).await?;
90 if let Some(winner) = classification.argmax(false)?
91 && let Some(tier) = Self::tier_for(winner.category)
92 {
93 set_fall_open(state, tier);
94 if let Some(identity) = identity {
95 self.retain(identity, tier);
96 }
97 return Ok(());
98 }
99 if self.trigger == ClassifyTrigger::NewSession
100 && let Some(identity) = identity.as_ref()
101 {
102 self.retain(identity.clone(), self.default_tier);
104 }
105 }
106 if let Some(tier) = identity
109 .and_then(|identity| self.tiers.lock().get(&identity).copied())
110 .or_else(|| fall_open_tier(state))
111 {
112 set_fall_open(state, tier);
113 driver.set_evidence_if_empty(serde_json::json!({"source": "retained"}));
114 }
115 Ok(())
116 }
117}
118
119pub enum CompositeJudgeConfig {
121 Capability(TaskClassifierConfig),
123 Custom(CustomClassifierConfig),
125}
126
127pub struct CompositeRouterConfig {
129 pub judge: CompositeJudgeConfig,
131 pub stage: StageRouterConfig,
133}
134
135pub struct CompositeRouter {
137 route: FallThrough,
138}
139
140impl CompositeRouter {
141 pub fn new(config: CompositeRouterConfig) -> Result<Self> {
148 let setter = Self::tier_setter(config.judge, config.stage.mode.default_tier())?;
149 let route = build_stage_route(config.stage, setter.trigger == ClassifyTrigger::UserTurn)?
150 .with_name(COMPOSITE)
151 .with_processor(Arc::new(setter));
152 Ok(Self { route })
153 }
154
155 pub fn new_with_profiles(
157 judge: CompositeJudgeConfig,
158 mut profile_0: StageRouterConfig,
159 mut profile_1: StageRouterConfig,
160 ) -> Result<Self> {
161 for profile in [&mut profile_0, &mut profile_1] {
162 if profile.mode == PickerMode::CapableFirst {
163 profile.efficient_hold_turns.get_or_insert(-1);
164 }
165 }
166 let setter = Self::tier_setter(judge, Tier::Efficient)?;
167 let reset_on_user_turn = setter.trigger == ClassifyTrigger::UserTurn;
168 let profiles = StageProfiles([
169 build_stage_route(profile_0, reset_on_user_turn)?,
170 build_stage_route(profile_1, reset_on_user_turn)?,
171 ]);
172 let route = FallThrough::new_with_state()
173 .with_name(COMPOSITE)
174 .with_processor(Arc::new(setter))
175 .with_classifier(Arc::new(profiles));
176 Ok(Self { route })
177 }
178
179 fn tier_setter(judge: CompositeJudgeConfig, default_tier: Tier) -> Result<TierSetter> {
180 let (trigger, message_hash_fallback) = match &judge {
181 CompositeJudgeConfig::Capability(judge) => {
182 (judge.classify_trigger, judge.message_hash_fallback)
183 }
184 CompositeJudgeConfig::Custom(config) => {
185 (config.classify_trigger, config.message_hash_fallback)
186 }
187 };
188 if trigger == ClassifyTrigger::EveryRequest {
189 return Err(LibsyError::AlgorithmError {
190 message: "composite: classify_trigger must be user_turn or new_session".to_string(),
191 });
192 }
193 let judge: Arc<dyn Classifier> = match judge {
194 CompositeJudgeConfig::Capability(judge) => {
195 Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
197 config: TaskClassifierConfig {
198 classify_trigger: ClassifyTrigger::EveryRequest,
199 message_hash_fallback: false,
200 ..judge
201 },
202 })?)
203 }
204 CompositeJudgeConfig::Custom(config) => {
205 LlmTaskClassifier::custom_classifier(&config, true)?
206 }
207 };
208 Ok(TierSetter {
209 judge,
210 trigger,
211 message_hash_fallback,
212 default_tier,
213 tiers: Mutex::new(HashMap::new()),
214 })
215 }
216}
217
218struct StageProfiles([FallThrough; 2]);
219
220#[async_trait]
221impl Classifier for StageProfiles {
222 async fn score(
223 &self,
224 state: &mut State,
225 request: &mut Request,
226 driver: &Driver,
227 ) -> Result<(Classification, Option<Response>)> {
228 let (profile, prefix) = match fall_open_tier(state) {
229 Some(Tier::Capable) => (&self.0[1], "composite.profile_1."),
230 _ => (&self.0[0], "composite.profile_0."),
231 };
232 let mut profile_state = State {
234 extra: state
235 .extra
236 .iter()
237 .filter_map(|(key, value)| {
238 key.strip_prefix(prefix)
239 .map(|key| (key.to_string(), value.clone()))
240 })
241 .collect(),
242 ..State::default()
243 };
244 let result = profile.route(&mut profile_state, driver, request).await;
245 state.extra.retain(|key, _| !key.starts_with(prefix));
246 state.extra.extend(
247 profile_state
248 .extra
249 .into_iter()
250 .map(|(key, value)| (format!("{prefix}{key}"), value)),
251 );
252 state.tool_signals = profile_state.tool_signals;
253 state.target_prompts = profile_state.target_prompts;
254 let (score, response) = result?;
255 Ok((Classification::Scores(vec![score]), response))
256 }
257}
258
259#[async_trait]
260impl Algorithm for CompositeRouter {
261 fn name(&self) -> &str {
262 COMPOSITE
263 }
264
265 async fn route(
266 self: Arc<Self>,
267 driver: Driver,
268 request: Request,
269 ) -> Result<crate::RoutingOutcome> {
270 self.route.execute(driver, request).await
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use crate::{CapabilityJudgeConfig, LlmCapabilityConfig};
277 use std::collections::HashMap;
278 use std::sync::Arc;
279
280 use switchyard_protocol::{Category, LlmClientError, Message, ModelId, Role};
281
282 use super::*;
283 use crate::algorithms::util::tier_fixtures::{JUDGE, Recorder, turn_request};
284 use crate::core::testing::test_drive_with_models;
285
286 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
287 [
288 (Category::Judge, vec![ModelId::from(JUDGE)]),
289 (Category::Efficient, vec![ModelId::from("weak")]),
290 (Category::Capable, vec![ModelId::from("strong")]),
291 (
292 Category::Any,
293 vec![ModelId::from("strong"), ModelId::from("weak")],
294 ),
295 ]
296 .into()
297 }
298
299 fn user_turn_request() -> Request {
300 let mut request = turn_request(false);
301 request
302 .llm_request
303 .messages
304 .push(Message::text(Role::User, "now rewrite the parser"));
305 request
306 }
307
308 fn unkeyed(mut request: Request) -> Request {
310 if let Some(metadata) = request.metadata.as_mut() {
311 metadata.session_id = None;
312 }
313 request
314 }
315
316 fn hash_keyed_router() -> Result<Arc<CompositeRouter>> {
317 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
318 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
319 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
320 base_threshold: 0.5,
321 ..LlmCapabilityConfig::default()
322 }),
323 classify_trigger: ClassifyTrigger::UserTurn,
324 message_hash_fallback: true,
325 ..Default::default()
326 }),
327 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
328 })?))
329 }
330
331 fn router() -> Result<Arc<CompositeRouter>> {
332 router_with_trigger(ClassifyTrigger::UserTurn)
333 }
334
335 #[tokio::test]
336 async fn capable_first_defaults_to_pinning_efficient_after_a_switch() -> Result<()> {
337 for capable_first in [false, true] {
338 for turns in [None, Some(0), Some(1), Some(-1)] {
339 let mut stage = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
340 stage.efficient_hold_turns = turns;
341 let router = router_with_stage(ClassifyTrigger::UserTurn, stage)?;
342 let recorder = Arc::new(Recorder::default());
343 *recorder.judge_p_solve.lock() = if capable_first { 0.0 } else { 1.0 };
344 let mut initial = turn_request(false);
345 initial.llm_request.messages.truncate(1);
346 let mut write = turn_request(false);
347 if let switchyard_protocol::ContentBlock::ToolCall(call) =
348 &mut write.llm_request.messages[1].content[0]
349 {
350 call.name = "Write".into();
351 }
352 for request in [
353 initial,
354 turn_request(true),
355 write.clone(),
356 write.clone(),
357 write,
358 turn_request(true),
359 turn_request(true),
360 turn_request(true),
361 ] {
362 test_drive_with_models(
363 router.clone(),
364 request,
365 runtime_models(),
366 recorder.serve(),
367 )
368 .await?;
369 }
370 let actual: Vec<_> = recorder
371 .routed()
372 .into_iter()
373 .map(|call| call.target)
374 .collect();
375 let initial = if capable_first { "strong" } else { "weak" };
376 let mut expected = vec![initial, "strong", "strong", "strong", "weak"];
377 let count = turns.unwrap_or(if capable_first { -1 } else { 0 });
378 expected.extend((0..3).map(|i| {
379 if count == -1 || i < count {
380 "weak"
381 } else {
382 "strong"
383 }
384 }));
385 assert_eq!(
386 actual, expected,
387 "capable_first={capable_first}, turns={turns:?}"
388 );
389 assert_eq!(recorder.judge_calls(), 1);
390 }
391 }
392 Ok(())
393 }
394
395 fn router_with_trigger(classify_trigger: ClassifyTrigger) -> Result<Arc<CompositeRouter>> {
396 router_with_stage(
397 classify_trigger,
398 StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
399 )
400 }
401
402 fn router_with_stage(
403 classify_trigger: ClassifyTrigger,
404 stage: StageRouterConfig,
405 ) -> Result<Arc<CompositeRouter>> {
406 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
407 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
408 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
409 base_threshold: 0.5,
410 ..LlmCapabilityConfig::default()
411 }),
412 classify_trigger,
413 ..Default::default()
414 }),
415 stage,
416 })?))
417 }
418
419 #[tokio::test]
420 async fn profiles_reset_holds_and_keep_prompts_when_the_judge_switches() -> Result<()> {
421 for (has_session_id, picker, first_target, first_prompt) in [
422 (false, PickerMode::EfficientFirst, "weak", "EF efficient"),
423 (true, PickerMode::EfficientFirst, "weak", "EF efficient"),
424 (true, PickerMode::CapableFirst, "strong", "EF capable"),
425 ] {
426 let mut efficient = StageRouterConfig::new(picker, 0.5);
427 efficient.capable_hold_turns = -1;
428 efficient.capable_system_prompt = Some("EF capable".into());
429 efficient.efficient_system_prompt = Some("EF efficient".into());
430 let mut capable = StageRouterConfig::new(PickerMode::CapableFirst, 0.0);
431 capable.capable_hold_turns = 0;
432 capable.capable_system_prompt = Some("CF capable".into());
433 capable.efficient_system_prompt = Some("CF efficient".into());
434 for stage in [&mut efficient, &mut capable] {
435 stage.llm_fallback = Some(crate::algorithms::stage::LlmFallback {
436 target: None,
437 config: Default::default(),
438 });
439 }
440 let router = Arc::new(CompositeRouter::new_with_profiles(
441 CompositeJudgeConfig::Capability(TaskClassifierConfig {
442 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
443 base_threshold: 0.5,
444 ..Default::default()
445 }),
446 classify_trigger: ClassifyTrigger::UserTurn,
447 ..Default::default()
448 }),
449 efficient,
450 capable,
451 )?);
452 let recorder = Arc::new(Recorder::default());
453 let mut write = turn_request(false);
454 if let switchyard_protocol::ContentBlock::ToolCall(call) =
455 &mut write.llm_request.messages[1].content[0]
456 {
457 call.name = "Write".into();
458 }
459 let mut context_update = write.clone();
460 context_update.llm_request.messages.push(Message::text(
461 Role::User,
462 "<environment_context><current_date>2026-10-31</current_date></environment_context>",
463 ));
464 let mut state = State::default();
465 for (p_solve, request, target, prompt) in [
466 (1.0, user_turn_request(), first_target, first_prompt),
467 (1.0, turn_request(true), "strong", "EF capable"),
468 (0.0, user_turn_request(), "strong", "CF capable"),
469 (0.0, write, "weak", "CF efficient"),
470 (1.0, context_update, "weak", "CF efficient"),
471 (1.0, user_turn_request(), first_target, first_prompt),
472 (0.0, user_turn_request(), "strong", "CF capable"),
473 ] {
474 *recorder.judge_p_solve.lock() = p_solve;
475 let request = if has_session_id {
476 request
477 } else {
478 unkeyed(request)
479 };
480 let outcome = crate::core::testing::test_drive_with_state(
481 router.clone(),
482 request,
483 runtime_models(),
484 state,
485 recorder.serve(),
486 )
487 .await?;
488 assert_eq!(outcome.selected_model_id()?.as_str(), target);
489 assert_eq!(outcome.request.llm_request.instructions.len(), 1);
490 assert_eq!(
491 outcome.request.llm_request.instructions[0].content,
492 vec![switchyard_protocol::ContentBlock::Text {
493 text: prompt.into()
494 }]
495 );
496 state = outcome.state;
497 }
498 assert_eq!(recorder.judge_calls(), 4);
499 }
500 Ok(())
501 }
502
503 #[test]
504 fn rejects_every_request_as_a_trigger() {
505 for judge in [
506 CompositeJudgeConfig::Capability(TaskClassifierConfig::default()),
507 CompositeJudgeConfig::Custom(CustomClassifierConfig::new(
508 "Choose a tier",
509 serde_json::json!({"type": "object"}),
510 crate::CustomClassifierPolicy::target_selector("/decision/target"),
511 )),
512 ] {
513 let error = CompositeRouter::new(CompositeRouterConfig {
514 judge,
515 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
516 })
517 .err()
518 .expect("every_request must be rejected for both classifier modes");
519 assert!(matches!(error, LibsyError::AlgorithmError { message }
520 if message == "composite: classify_trigger must be user_turn or new_session"));
521 }
522 }
523
524 fn custom_judge(trigger: ClassifyTrigger) -> CompositeJudgeConfig {
525 let mut config = CustomClassifierConfig::new(
526 "Select a tier",
527 serde_json::json!({"type": "object"}),
528 crate::CustomClassifierPolicy::target_selector("/decision/target"),
529 );
530 config.classify_trigger = trigger;
531 CompositeJudgeConfig::Custom(config)
532 }
533
534 #[tokio::test]
535 async fn a_failed_session_judgment_retains_the_configured_stage_default() -> Result<()> {
536 for custom in [false, true] {
537 for (mode, expected) in [
538 (PickerMode::EfficientFirst, "weak"),
539 (PickerMode::CapableFirst, "strong"),
540 ] {
541 let judge = if custom {
542 custom_judge(ClassifyTrigger::NewSession)
543 } else {
544 CompositeJudgeConfig::Capability(TaskClassifierConfig {
545 classify_trigger: ClassifyTrigger::NewSession,
546 ..Default::default()
547 })
548 };
549 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
550 judge,
551 stage: StageRouterConfig::new(mode, 0.5),
552 })?);
553 let calls = Arc::new(Mutex::new(0));
554 let serve = {
555 let calls = calls.clone();
556 move |target: ModelId, _request: Request| {
557 let completion = if target.as_str() == JUDGE {
558 *calls.lock() += 1;
559 "not JSON".to_string()
560 } else {
561 target.to_string()
562 };
563 async move { Ok(crate::core::testing::reply(completion)) }
564 }
565 };
566 for request in [
567 user_turn_request(),
568 turn_request(false),
569 user_turn_request(),
570 ] {
571 let (selected, _) = test_drive_with_models(
572 router.clone(),
573 request,
574 runtime_models(),
575 serve.clone(),
576 )
577 .await?;
578 assert_eq!(selected.as_str(), expected);
579 }
580 assert_eq!(
581 *calls.lock(),
582 1,
583 "new_session must retain a failed judgment"
584 );
585 }
586 }
587 Ok(())
588 }
589
590 #[tokio::test]
591 async fn a_custom_judge_sets_the_tier_and_retains_it_on_failure() -> Result<()> {
592 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
593 judge: custom_judge(ClassifyTrigger::UserTurn),
594 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
595 })?);
596 let replies = Arc::new(Mutex::new(
597 [
598 Some(r#"{"decision":{"target":"capable"}}"#),
599 None,
600 Some("not JSON"),
601 Some(r#"{"decision":{"target":"efficient"}}"#),
602 ]
603 .into_iter(),
604 ));
605 let serve = {
606 let replies = replies.clone();
607 move |target: ModelId, _request: Request| {
608 let completion = if target.as_str() == JUDGE {
609 replies
610 .lock()
611 .next()
612 .expect("unexpected judge call")
613 .map(str::to_string)
614 } else {
615 Some(target.to_string())
616 };
617 async move {
618 completion
619 .map(crate::core::testing::reply)
620 .ok_or_else(|| LlmClientError::General("judge unavailable".into()))
621 }
622 }
623 };
624 for (request, expected) in [
625 (user_turn_request(), "strong"),
626 (turn_request(false), "strong"),
627 (user_turn_request(), "strong"),
628 (user_turn_request(), "strong"),
629 (user_turn_request(), "weak"),
630 ] {
631 let (selected, _) =
632 test_drive_with_models(router.clone(), request, runtime_models(), serve.clone())
633 .await?;
634 assert_eq!(selected.as_str(), expected);
635 }
636 assert!(replies.lock().next().is_none());
637 Ok(())
638 }
639
640 #[tokio::test]
644 async fn a_judge_sharing_the_capable_model_still_latches_the_tier() -> Result<()> {
645 let models: HashMap<Category, Vec<ModelId>> = [
646 (Category::Judge, vec![ModelId::from("strong")]),
647 (Category::Capable, vec![ModelId::from("strong")]),
648 (Category::Efficient, vec![ModelId::from("weak")]),
649 (
650 Category::Any,
651 vec![ModelId::from("strong"), ModelId::from("weak")],
652 ),
653 ]
654 .into();
655 let calls = Arc::new(Mutex::new(0u32));
657 let serve = {
658 let calls = Arc::clone(&calls);
659 move |target: ModelId, _request: Request| {
660 let calls = Arc::clone(&calls);
661 async move {
662 let mut calls = calls.lock();
663 *calls += 1;
664 let completion = if *calls == 1 {
665 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.1}"#.to_string()
668 } else {
669 target.to_string()
670 };
671 Ok(crate::core::testing::reply(completion))
672 }
673 }
674 };
675 let router = router()?;
676
677 test_drive_with_models(
678 router.clone(),
679 user_turn_request(),
680 models.clone(),
681 serve.clone(),
682 )
683 .await?;
684 let (selected, _) =
686 test_drive_with_models(router, turn_request(false), models, serve).await?;
687
688 assert_eq!(selected, ModelId::from("strong"));
689 Ok(())
690 }
691
692 #[tokio::test]
693 async fn a_session_without_an_id_keys_on_the_message_hash() -> Result<()> {
694 let recorder = Arc::new(Recorder::default());
695 *recorder.judge_p_solve.lock() = 0.1;
696 let router = hash_keyed_router()?;
697
698 test_drive_with_models(
699 router.clone(),
700 unkeyed(user_turn_request()),
701 runtime_models(),
702 recorder.serve(),
703 )
704 .await?;
705 test_drive_with_models(
706 router.clone(),
707 unkeyed(turn_request(false)),
708 runtime_models(),
709 recorder.serve(),
710 )
711 .await?;
712
713 assert_eq!(
714 recorder.judge_calls(),
715 1,
716 "a tool step is not a user turn, session id or not"
717 );
718 assert_eq!(
719 recorder.routed()[1].target,
720 "strong",
721 "and the tier survives the tool step"
722 );
723 Ok(())
724 }
725
726 #[tokio::test]
727 async fn the_judge_sets_the_tier_once_a_turn_and_the_signals_run_within_it() -> Result<()> {
728 let recorder = Arc::new(Recorder::default());
729 *recorder.judge_p_solve.lock() = 0.1;
730 let router = router()?;
731
732 test_drive_with_models(
733 router.clone(),
734 user_turn_request(),
735 runtime_models(),
736 recorder.serve(),
737 )
738 .await?;
739 test_drive_with_models(
740 router.clone(),
741 turn_request(false),
742 runtime_models(),
743 recorder.serve(),
744 )
745 .await?;
746
747 let routed = recorder.routed();
748 assert_eq!(
749 routed[0].target, "strong",
750 "a quiet turn falls open to the verdict"
751 );
752 assert_eq!(
753 routed[1].target, "strong",
754 "which holds across the tool steps after it"
755 );
756 assert_eq!(
757 recorder.judge_calls(),
758 1,
759 "a tool step is not a new user turn"
760 );
761 Ok(())
762 }
763
764 #[tokio::test]
765 async fn caller_state_carries_the_judged_tier_without_a_session_id() -> Result<()> {
766 for trigger in [ClassifyTrigger::UserTurn, ClassifyTrigger::NewSession] {
767 let recorder = Arc::new(Recorder::default());
768 *recorder.judge_p_solve.lock() = 0.1;
769 let router = router_with_trigger(trigger)?;
770 let run = |request, state| {
771 crate::core::testing::test_drive_with_state(
772 router.clone(),
773 request,
774 runtime_models(),
775 state,
776 recorder.serve(),
777 )
778 };
779
780 let first = run(unkeyed(user_turn_request()), State::default()).await?;
781 assert_eq!(first.selected_model_id()?, &ModelId::from("strong"));
782
783 let continued = run(unkeyed(turn_request(false)), first.state).await?;
784 assert_eq!(continued.selected_model_id()?, &ModelId::from("strong"));
785 assert_eq!(
786 continued.metadata.and_then(|m| m.evidence),
787 Some(serde_json::json!({"source": "retained"}))
788 );
789 assert_eq!(recorder.judge_calls(), 1, "{trigger:?}");
790 }
791 Ok(())
792 }
793}