diff --git a/codex-rs/context-fragments/src/fragment.rs b/codex-rs/context-fragments/src/fragment.rs index 5f44e3353011..b5c450091771 100644 --- a/codex-rs/context-fragments/src/fragment.rs +++ b/codex-rs/context-fragments/src/fragment.rs @@ -46,6 +46,11 @@ impl FragmentRegistration for FragmentRegistrationPro pub trait ContextualUserFragment { fn role(&self) -> &'static str; + /// Whether this fragment must be recorded as its own response item. + fn requires_separate_message(&self) -> bool { + false + } + fn markers(&self) -> (&'static str, &'static str); fn body(&self) -> String; diff --git a/codex-rs/core/src/context/model_switch_instructions.rs b/codex-rs/core/src/context/model_switch_instructions.rs index b1bdebbc9e37..3b86943c661f 100644 --- a/codex-rs/core/src/context/model_switch_instructions.rs +++ b/codex-rs/core/src/context/model_switch_instructions.rs @@ -18,6 +18,10 @@ impl ContextualUserFragment for ModelSwitchInstructions { "developer" } + fn requires_separate_message(&self) -> bool { + true + } + fn markers(&self) -> (&'static str, &'static str) { Self::type_markers() } diff --git a/codex-rs/core/src/context/personality_spec_instructions.rs b/codex-rs/core/src/context/personality_spec_instructions.rs index f584486d087b..8ab92bfcdd2e 100644 --- a/codex-rs/core/src/context/personality_spec_instructions.rs +++ b/codex-rs/core/src/context/personality_spec_instructions.rs @@ -16,6 +16,10 @@ impl ContextualUserFragment for PersonalitySpecInstructions { "developer" } + fn requires_separate_message(&self) -> bool { + true + } + fn markers(&self) -> (&'static str, &'static str) { Self::type_markers() } diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 609a124aee27..7fddf2da8761 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -3,8 +3,10 @@ mod apps_instructions; mod collaboration_mode; mod environment; mod environments_instructions; +mod model; mod multi_agent_mode; mod permissions; +mod personality; mod plugins_instructions; mod realtime; #[cfg(test)] @@ -32,8 +34,10 @@ pub(crate) use apps_instructions::AppsInstructionsState; pub(crate) use collaboration_mode::CollaborationModeState; pub(crate) use environment::EnvironmentsState; pub(crate) use environments_instructions::EnvironmentsInstructionsState; +pub(crate) use model::ModelInstructionsState; pub(crate) use multi_agent_mode::MultiAgentModeState; pub(crate) use permissions::PermissionsState; +pub(crate) use personality::PersonalityState; pub(crate) use plugins_instructions::PluginsInstructionsState; pub(crate) use realtime::RealtimeState; pub(crate) use tools::ToolsState; diff --git a/codex-rs/core/src/context/world_state/model.rs b/codex-rs/core/src/context/world_state/model.rs new file mode 100644 index 000000000000..c353034160fa --- /dev/null +++ b/codex-rs/core/src/context/world_state/model.rs @@ -0,0 +1,65 @@ +use super::PreviousSectionState; +use super::WorldStateSection; +use crate::context::ContextualUserFragment; +use crate::context::ModelSwitchInstructions; + +/// Model identity and the instructions needed when that identity changes. +#[derive(Clone, Debug)] +pub(crate) struct ModelInstructionsState { + model: String, + previous_model: Option, + instructions: String, +} + +impl ModelInstructionsState { + pub(crate) fn new(model: &str, previous_model: Option<&str>, instructions: String) -> Self { + Self { + model: model.to_string(), + previous_model: previous_model.map(str::to_string), + instructions, + } + } +} + +impl WorldStateSection for ModelInstructionsState { + const ID: &'static str = "model"; + type Snapshot = String; + + fn snapshot(&self) -> Self::Snapshot { + self.model.clone() + } + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + role == "developer" && ModelSwitchInstructions::matches_text(text) + } + + fn has_retained_fragment_matcher() -> bool { + true + } + + fn matches_retained_fragment(role: &str, text: &str) -> bool { + Self::matches_legacy_fragment(role, text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + let model_changed = match previous { + PreviousSectionState::Known(previous) => previous != &self.model, + PreviousSectionState::Unknown | PreviousSectionState::Absent => self + .previous_model + .as_deref() + .is_some_and(|previous| previous != self.model), + }; + + (model_changed && !self.instructions.is_empty()).then(|| { + Box::new(ModelSwitchInstructions::new(self.instructions.clone())) + as Box + }) + } +} + +#[cfg(test)] +#[path = "model_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/world_state/model_tests.rs b/codex-rs/core/src/context/world_state/model_tests.rs new file mode 100644 index 000000000000..806718cc4f98 --- /dev/null +++ b/codex-rs/core/src/context/world_state/model_tests.rs @@ -0,0 +1,35 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn model_change_renders_when_persisted_or_inferred_from_previous_turn() { + let state = ModelInstructionsState::new("gpt-new", Some("gpt-old"), "instructions".into()); + let previous = "gpt-old".to_string(); + + for previous in [ + PreviousSectionState::Known(&previous), + PreviousSectionState::Unknown, + PreviousSectionState::Absent, + ] { + assert_eq!( + state + .render_diff(previous) + .expect("model change should render") + .markers(), + ModelSwitchInstructions::type_markers() + ); + } +} + +#[test] +fn unchanged_model_does_not_render() { + let state = ModelInstructionsState::new("gpt-test", Some("gpt-test"), "instructions".into()); + let previous = "gpt-test".to_string(); + + assert!( + state + .render_diff(PreviousSectionState::Known(&previous)) + .is_none() + ); + assert!(state.render_diff(PreviousSectionState::Absent).is_none()); +} diff --git a/codex-rs/core/src/context/world_state/personality.rs b/codex-rs/core/src/context/world_state/personality.rs new file mode 100644 index 000000000000..b8672327de1f --- /dev/null +++ b/codex-rs/core/src/context/world_state/personality.rs @@ -0,0 +1,101 @@ +use super::PreviousSectionState; +use super::WorldStateSection; +use crate::context::ContextualUserFragment; +use crate::context::PersonalitySpecInstructions; +use codex_protocol::config_types::Personality; +use serde::Deserialize; +use serde::Serialize; + +/// Personality instructions currently visible to the model. +#[derive(Clone, Debug)] +pub(crate) struct PersonalityState { + snapshot: PersonalitySnapshot, + previous: Option, + instructions: Option, + personality_is_baked: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub(crate) struct PersonalitySnapshot { + model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + personality: Option, +} + +impl PersonalityState { + pub(crate) fn new( + model: &str, + personality: Option, + previous_model: Option<&str>, + previous_personality: Option, + instructions: Option, + personality_is_baked: bool, + ) -> Self { + Self { + snapshot: PersonalitySnapshot { + model: model.to_string(), + personality, + }, + previous: previous_model.map(|model| PersonalitySnapshot { + model: model.to_string(), + personality: previous_personality, + }), + instructions, + personality_is_baked, + } + } + + fn render_change( + &self, + previous: &PersonalitySnapshot, + ) -> Option> { + (previous.model == self.snapshot.model && previous.personality != self.snapshot.personality) + .then_some(self.instructions.as_ref()) + .flatten() + .map(|instructions| { + Box::new(PersonalitySpecInstructions::new(instructions.clone())) + as Box + }) + } +} + +impl WorldStateSection for PersonalityState { + const ID: &'static str = "personality"; + type Snapshot = PersonalitySnapshot; + + fn snapshot(&self) -> Self::Snapshot { + self.snapshot.clone() + } + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + role == "developer" && PersonalitySpecInstructions::matches_text(text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + match previous { + PreviousSectionState::Known(previous) => self.render_change(previous), + PreviousSectionState::Unknown => self + .previous + .as_ref() + .and_then(|previous| self.render_change(previous)), + PreviousSectionState::Absent => (!self.personality_is_baked + && self + .previous + .as_ref() + .is_none_or(|previous| previous.model == self.snapshot.model)) + .then_some(self.instructions.as_ref()) + .flatten() + .map(|instructions| { + Box::new(PersonalitySpecInstructions::new(instructions.clone())) + as Box + }), + } + } +} + +#[cfg(test)] +#[path = "personality_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/world_state/personality_tests.rs b/codex-rs/core/src/context/world_state/personality_tests.rs new file mode 100644 index 000000000000..57970497c1aa --- /dev/null +++ b/codex-rs/core/src/context/world_state/personality_tests.rs @@ -0,0 +1,106 @@ +use super::*; +use crate::context::world_state::WorldState; +use pretty_assertions::assert_eq; + +fn state( + model: &str, + personality: Option, + previous: Option<(&str, Option)>, + personality_is_baked: bool, +) -> PersonalityState { + PersonalityState::new( + model, + personality, + previous.map(|(model, _)| model), + previous.and_then(|(_, personality)| personality), + personality.map(|personality| format!("instructions for {personality:?}")), + personality_is_baked, + ) +} + +#[test] +fn initial_personality_renders_only_when_missing_from_base_instructions() { + let separate = state( + "gpt-test", + Some(Personality::Friendly), + /*previous*/ None, + /*personality_is_baked*/ false, + ); + let baked = state( + "gpt-test", + Some(Personality::Friendly), + /*previous*/ None, + /*personality_is_baked*/ true, + ); + + assert_eq!( + separate + .render_diff(PreviousSectionState::Absent) + .expect("separate personality should render") + .markers(), + PersonalitySpecInstructions::type_markers() + ); + assert!(baked.render_diff(PreviousSectionState::Absent).is_none()); +} + +#[test] +fn personality_changes_render_without_repeating_model_changes() { + let previous = PersonalitySnapshot { + model: "gpt-test".to_string(), + personality: Some(Personality::Friendly), + }; + let changed = state( + "gpt-test", + Some(Personality::Pragmatic), + Some(("gpt-test", Some(Personality::Friendly))), + /*personality_is_baked*/ true, + ); + let model_changed = state( + "gpt-next", + Some(Personality::Pragmatic), + Some(("gpt-test", Some(Personality::Friendly))), + /*personality_is_baked*/ true, + ); + + assert_eq!( + changed + .render_diff(PreviousSectionState::Known(&previous)) + .expect("changed personality should render") + .markers(), + PersonalitySpecInstructions::type_markers() + ); + assert!( + model_changed + .render_diff(PreviousSectionState::Known(&previous)) + .is_none() + ); + assert!( + model_changed + .render_diff(PreviousSectionState::Unknown) + .is_none() + ); + assert!( + model_changed + .render_diff(PreviousSectionState::Absent) + .is_none() + ); +} + +#[test] +fn persisted_personality_does_not_require_a_retained_update() { + let state = state( + "gpt-test", + Some(Personality::Friendly), + Some(("gpt-test", Some(Personality::Friendly))), + /*personality_is_baked*/ false, + ); + let mut world_state = WorldState::default(); + world_state.add_section(state); + let snapshot = world_state.snapshot(); + + assert!( + world_state + .render_history_diff(Some(&snapshot), &[]) + .is_empty() + ); +} diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 6266428bbeb2..e07f7bd24e1e 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,64 +1,11 @@ use crate::context::ContextualUserFragment; -use crate::context::ModelSwitchInstructions; -use crate::context::PersonalitySpecInstructions; -use crate::session::PreviousTurnSettings; -use crate::session::turn_context::TurnContext; -use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ModelInfo; -use codex_protocol::protocol::TurnContextItem; -fn build_personality_update_item( - previous: Option<&TurnContextItem>, - next: &TurnContext, - personality_feature_enabled: bool, -) -> Option { - if !personality_feature_enabled { - return None; - } - let previous = previous?; - if next.model_info.slug != previous.model { - return None; - } - - if let Some(personality) = next.personality - && next.personality != previous.personality - { - let model_info = &next.model_info; - let personality_message = personality_message_for(model_info, personality); - personality_message.map(|message| PersonalitySpecInstructions::new(message).render()) - } else { - None - } -} - -pub(crate) fn personality_message_for( - model_info: &ModelInfo, - personality: Personality, -) -> Option { - model_info - .model_messages - .as_ref() - .and_then(|spec| spec.get_personality_message(Some(personality))) - .filter(|message| !message.is_empty()) -} - -pub(crate) fn build_model_instructions_update_item( - previous_turn_settings: Option<&PreviousTurnSettings>, - next: &TurnContext, -) -> Option { - let previous_turn_settings = previous_turn_settings?; - if previous_turn_settings.model == next.model_info.slug { - return None; - } - - let model_instructions = next.model_info.get_model_instructions(next.personality); - if model_instructions.is_empty() { - return None; - } - - Some(ModelSwitchInstructions::new(model_instructions).render()) +#[derive(Clone, Copy, PartialEq, Eq)] +enum MessageGroup { + Standalone, + Mergeable, } pub(crate) fn build_developer_update_item(text_sections: Vec) -> Option { @@ -72,20 +19,29 @@ pub(crate) fn build_contextual_user_message(text_sections: Vec) -> Optio pub(crate) fn merge_contextual_fragments( fragments: Vec>, ) -> Vec { - let mut messages: Vec<(&str, Vec)> = Vec::with_capacity(fragments.len()); + let mut messages: Vec<(&str, MessageGroup, Vec)> = Vec::with_capacity(fragments.len()); for fragment in fragments { let role = fragment.role(); + let group = if fragment.requires_separate_message() { + MessageGroup::Standalone + } else { + MessageGroup::Mergeable + }; let text = fragment.render(); match messages.last_mut() { - Some((previous_role, text_sections)) if *previous_role == role => { + Some((previous_role, previous_group, text_sections)) + if *previous_role == role + && *previous_group == MessageGroup::Mergeable + && group == MessageGroup::Mergeable => + { text_sections.push(text); } - _ => messages.push((role, vec![text])), + _ => messages.push((role, group, vec![text])), } } messages .into_iter() - .filter_map(|(role, text_sections)| build_text_message(role, text_sections)) + .filter_map(|(role, _, text_sections)| build_text_message(role, text_sections)) .collect() } @@ -107,28 +63,3 @@ fn build_text_message(role: &str, text_sections: Vec) -> Option, - previous_turn_settings: Option<&PreviousTurnSettings>, - next: &TurnContext, - personality_feature_enabled: bool, -) -> Vec { - // TODO(ccunningham): build_settings_update_items still does not cover every - // model-visible item emitted by build_initial_context. Persist the remaining - // inputs or add explicit replay events so fork/resume can diff everything - // deterministically. - let developer_update_sections = [ - // Keep model-switch instructions first so model-specific guidance is read before - // any other context diffs on this turn. - build_model_instructions_update_item(previous_turn_settings, next), - build_personality_update_item(previous, next, personality_feature_enabled), - ] - .into_iter() - .flatten() - .collect(); - - build_developer_update_item(developer_update_sections) - .into_iter() - .collect() -} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e06dfb569fa4..122534185e87 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -26,8 +26,8 @@ use crate::config::resolve_tool_suggest_config_from_layer_stack; use crate::context::ApprovedCommandPrefixSaved; use crate::context::AvailableSkillsInstructions; use crate::context::ContextualUserFragment; +use crate::context::ModelSwitchInstructions; use crate::context::NetworkRuleSaved; -use crate::context::PersonalitySpecInstructions; use crate::context::RecommendedPluginsInstructions; use crate::context::world_state::WorldState; use crate::current_time::TimeProvider; @@ -1807,27 +1807,6 @@ impl Session { self.refresh_runtime_config(next_config).await; } - async fn build_settings_update_items( - &self, - reference_context_item: Option<&TurnContextItem>, - current_context: &TurnContext, - ) -> Vec { - // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem - // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible - // context (feature gates and the previous-turn bridge) should be persisted - // state or explicit non-state replay events. - let previous_turn_settings = { - let state = self.state.lock().await; - state.previous_turn_settings() - }; - crate::context_manager::updates::build_settings_update_items( - reference_context_item, - previous_turn_settings.as_ref(), - current_context, - self.features.enabled(Feature::Personality), - ) - } - /// Record a terminal CodexErr before the app-server completion notification is reduced. pub(crate) fn track_turn_codex_error(&self, turn_context: &TurnContext, error: &CodexErr) { self.services @@ -3351,23 +3330,13 @@ impl Session { let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); let mut separate_developer_sections = Vec::::new(); - let (previous_turn_settings, base_instructions, session_source, auto_compact_window_ids) = { + let (session_source, auto_compact_window_ids) = { let state = self.state.lock().await; ( - state.previous_turn_settings(), - state.session_configuration.base_instructions.clone(), state.session_configuration.session_source.clone(), state.auto_compact_window_ids(), ) }; - if let Some(model_switch_message) = - crate::context_manager::updates::build_model_instructions_update_item( - previous_turn_settings.as_ref(), - turn_context, - ) - { - developer_sections.push(model_switch_message); - } let separate_guardian_developer_message = crate::guardian::is_guardian_reviewer_source(&session_source); // Keep the guardian policy prompt out of the aggregated developer bundle so it @@ -3378,23 +3347,6 @@ impl Session { { developer_sections.push(developer_instructions.to_string()); } - if self.features.enabled(Feature::Personality) - && let Some(personality) = turn_context.personality - { - let model_info = turn_context.model_info.clone(); - let has_baked_personality = model_info.supports_personality() - && base_instructions == model_info.get_model_instructions(Some(personality)); - if !has_baked_personality - && let Some(personality_message) = - crate::context_manager::updates::personality_message_for( - &model_info, - personality, - ) - { - developer_sections - .push(PersonalitySpecInstructions::new(personality_message).render()); - } - } if turn_context.config.include_skill_instructions { let host_catalog_in_world_state = turn_context .extension_data @@ -3546,6 +3498,12 @@ impl Session { let mut initial_multi_agent_mode = None; for fragment in world_state.render_full() { match fragment.role() { + "developer" + if fragment.markers().0 == ModelSwitchInstructions::type_markers().0 => + { + // New-model instructions must precede the rest of the developer context. + developer_sections.insert(0, fragment.render()); + } "developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => { initial_multi_agent_mode = Some(fragment); } @@ -3720,11 +3678,6 @@ impl Session { Some(WorldStateItem::full(snapshot.into_value())), ) } else { - // Steady-state path: append only built-in context diffs here; turn-scoped extension - // context is added below. - let mut context_items = self - .build_settings_update_items(reference_context_item.as_ref(), turn_context) - .await; let (world_state_items, world_state_item) = { let mut state = self.state.lock().await; let (fragments, rollout_item) = @@ -3734,8 +3687,7 @@ impl Session { rollout_item, ) }; - context_items.extend(world_state_items); - (context_items, world_state_item) + (world_state_items, world_state_item) }; if !should_inject_full_context && turn_context_changed { context_items.extend( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 87e9ec0600e4..ec3107081df2 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9359,11 +9359,6 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline } let rollout_path = attach_thread_persistence(&mut session).await; - let update_items = session - .build_settings_update_items(Some(&previous_context_item), &turn_context) - .await; - assert_eq!(update_items, Vec::new()); - let turn_context = Arc::new(turn_context); let step_context = StepContext::for_test(Arc::clone(&turn_context)); session diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 2a3f3c01ff37..8d2b63d041ed 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -7,8 +7,10 @@ use crate::context::world_state::AppsInstructionsState; use crate::context::world_state::CollaborationModeState; use crate::context::world_state::EnvironmentsInstructionsState; use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::ModelInstructionsState; use crate::context::world_state::MultiAgentModeState; use crate::context::world_state::PermissionsState; +use crate::context::world_state::PersonalityState; use crate::context::world_state::PluginsInstructionsState; use crate::context::world_state::RealtimeState; use crate::context::world_state::ToolsState; @@ -27,6 +29,21 @@ impl Session { selected_capability_root_count = step_context.selected_capability_roots.len(), "building step world state" ); + let (previous_model, previous_context, base_instructions) = { + let state = self.state.lock().await; + ( + state + .previous_turn_settings() + .map(|previous| previous.model), + state.reference_context_item(), + state.session_configuration.base_instructions.clone(), + ) + }; + let model_instructions = turn_context + .model_info + .get_model_instructions(turn_context.personality); + let personality_is_baked = turn_context.model_info.supports_personality() + && base_instructions == model_instructions; let environment_subagents = if turn_context.config.include_environment_context { self.services .agent_control @@ -36,6 +53,33 @@ impl Session { String::new() }; let mut world_state = WorldState::default(); + world_state.add_section(ModelInstructionsState::new( + &turn_context.model_info.slug, + previous_model.as_deref(), + model_instructions, + )); + if self.features.enabled(Feature::Personality) { + let personality_instructions = turn_context.personality.and_then(|personality| { + turn_context + .model_info + .model_messages + .as_ref() + .and_then(|messages| messages.get_personality_message(Some(personality))) + .filter(|message| !message.is_empty()) + }); + world_state.add_section(PersonalityState::new( + &turn_context.model_info.slug, + turn_context.personality, + previous_context + .as_ref() + .map(|previous| previous.model.as_str()), + previous_context + .as_ref() + .and_then(|previous| previous.personality), + personality_instructions, + personality_is_baked, + )); + } world_state.add_section(RealtimeState::new( turn_context.realtime_active, turn_context diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 470709c7463b..00f2370819ed 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -20,6 +20,8 @@ use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_image_generation_call; @@ -179,6 +181,31 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( "expected model switch preamble, got: {model_switch_text:?}" ); + test.codex.ensure_rollout_materialized().await; + test.codex.flush_rollout().await?; + let rollout_path = test.codex.rollout_path().expect("rollout path"); + let model_states = std::fs::read_to_string(rollout_path)? + .lines() + .map(serde_json::from_str::) + .collect::>>()? + .into_iter() + .filter_map(|line| match line.item { + RolloutItem::WorldState(item) => item + .state + .get("model") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + _ => None, + }) + .collect::>(); + assert_eq!( + model_states, + vec![ + test.session_configured.model.clone(), + next_model.to_string() + ] + ); + Ok(()) }