From 8cb81f649021b86f12c4410e4c3f50addabd474e Mon Sep 17 00:00:00 2001 From: chetan-oai Date: Fri, 30 Oct 2026 18:07:48 +0000 Subject: [PATCH] Add an option to suppress incremental model change announcements (#58405) ## What changed Add the default-off `suppress_model_change_announcements` feature flag. When enabled, incremental context updates omit the model ID announcement after a model switch while still delivering changed base instructions and tool definitions. Advance the model snapshot even when a switch emits no context update, so subsequent turns recognize the current model. ## Testing Add coverage for omitted and explicit flag values, snapshot advancement without repeated updates, and request history across model switches, repeated turns, and compaction with announcements suppressed. GitOrigin-RevId: 89c70f1e9d7981fd945fb8e41b1f98492717a07e --- codex-rs/core/config.schema.json | 6 + .../core/src/context/world_state/model.rs | 16 ++- .../src/context/world_state/model_tests.rs | 35 +++++- codex-rs/core/src/session/world_state.rs | 9 +- .../suite/scenarios_incremental_tools.rs | 20 +++- ...mental_tools_suppressed_announcements.snap | 113 ++++++++++++++++++ codex-rs/features/src/lib.rs | 8 ++ codex-rs/features/src/tests.rs | 18 +++ 8 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools_suppressed_announcements.snap diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 51fd47b3b2..09bb33a12e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1368,6 +1368,9 @@ "subagent_thread_environments": { "type": "boolean" }, + "suppress_model_change_announcements": { + "type": "boolean" + }, "system_proxy_fallback": { "type": "boolean" }, @@ -8069,6 +8072,9 @@ "subagent_thread_environments": { "type": "boolean" }, + "suppress_model_change_announcements": { + "type": "boolean" + }, "system_proxy_fallback": { "type": "boolean" }, diff --git a/codex-rs/core/src/context/world_state/model.rs b/codex-rs/core/src/context/world_state/model.rs index 08461e6db2..a16fcb90ae 100644 --- a/codex-rs/core/src/context/world_state/model.rs +++ b/codex-rs/core/src/context/world_state/model.rs @@ -32,6 +32,7 @@ pub(crate) struct ModelInstructionsState { /// Ordinary-window fallback when the saved model snapshot is unavailable. previous_model: Option, instructions: String, + suppress_model_change_announcements: bool, } impl ModelInstructionsState { @@ -40,10 +41,15 @@ impl ModelInstructionsState { snapshot: ModelInstructionsSnapshot::ModelOnly(model.to_string()), previous_model: previous_model.map(str::to_string), instructions, + suppress_model_change_announcements: false, } } - pub(crate) fn for_incremental_tools(model: &str, instructions: String) -> Self { + pub(crate) fn for_incremental_tools( + model: &str, + instructions: String, + suppress_model_change_announcements: bool, + ) -> Self { let instructions = BaseInstructionsFragment(instructions); let instructions_hash = WorldStateHash::from_fragment(&instructions); Self { @@ -53,6 +59,7 @@ impl ModelInstructionsState { }, previous_model: None, instructions: instructions.0, + suppress_model_change_announcements, } } } @@ -114,12 +121,13 @@ impl WorldStateSection for ModelInstructionsState { let instructions_changed = instructions_hash.is_some_and(|current| { previous_instructions_hash.map_or(model_changed, |previous| previous != current) }); - if instructions_hash.is_some() && !model_changed && !instructions_changed { - return (None, Vec::new()); + let announce_model = model_changed && !self.suppress_model_change_announcements; + if instructions_hash.is_some() && !announce_model && !instructions_changed { + return (model_changed.then(|| self.snapshot.clone()), Vec::new()); } let fragment: Option> = if instructions_hash.is_some() { Some(Box::new(ModelInstructions { - model: model_changed.then(|| model.clone()), + model: announce_model.then(|| model.clone()), instructions: instructions_changed.then(|| self.instructions.clone()), })) } else { diff --git a/codex-rs/core/src/context/world_state/model_tests.rs b/codex-rs/core/src/context/world_state/model_tests.rs index 181e9b93c7..fc3746593f 100644 --- a/codex-rs/core/src/context/world_state/model_tests.rs +++ b/codex-rs/core/src/context/world_state/model_tests.rs @@ -51,12 +51,20 @@ fn unchanged_model_does_not_render() { #[test] fn legacy_model_snapshot_keeps_its_window_until_model_change_or_compaction() { let legacy: ModelInstructionsSnapshot = serde_json::from_str("\"gpt-old\"").unwrap(); - let same_model = ModelInstructionsState::for_incremental_tools("gpt-old", "new prompt".into()); + let same_model = ModelInstructionsState::for_incremental_tools( + "gpt-old", + "new prompt".into(), + /*suppress_model_change_announcements*/ false, + ); let (snapshot, updates) = same_model.render_diff(PreviousSectionState::Known(&legacy)); assert!(snapshot.is_none()); assert!(updates.is_empty()); - let new_model = ModelInstructionsState::for_incremental_tools("gpt-new", "new prompt".into()); + let new_model = ModelInstructionsState::for_incremental_tools( + "gpt-new", + "new prompt".into(), + /*suppress_model_change_announcements*/ false, + ); let (snapshot, updates) = new_model.render_diff(PreviousSectionState::Known(&legacy)); assert!(snapshot.is_some()); assert_eq!(updates.len(), 1); @@ -78,3 +86,26 @@ fn legacy_model_snapshot_keeps_its_window_until_model_change_or_compaction() { ); } } + +#[test] +fn suppressed_model_changes_advance_snapshot_without_repeated_updates() { + let initial = ModelInstructionsState::for_incremental_tools( + "gpt-old", + "instructions".into(), + /*suppress_model_change_announcements*/ true, + ); + let (snapshot, _) = initial.render_diff(PreviousSectionState::Absent); + let previous = snapshot.unwrap(); + let switched = ModelInstructionsState::for_incremental_tools( + "gpt-new", + "instructions".into(), + /*suppress_model_change_announcements*/ true, + ); + let (snapshot, updates) = switched.render_diff(PreviousSectionState::Known(&previous)); + assert!(updates.is_empty()); + let snapshot = snapshot.unwrap(); + assert_eq!(snapshot, switched.snapshot); + let (unchanged, updates) = switched.render_diff(PreviousSectionState::Known(&snapshot)); + assert!(unchanged.is_none()); + assert!(updates.is_empty()); +} diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index d6d38d839a..e2027dd10c 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -100,7 +100,14 @@ impl Session { Some(BaseInstructionsProvenance::Model { .. }) => render_current_instructions(), Some(BaseInstructionsProvenance::Custom) | None => base_instructions.text, }; - ModelInstructionsState::for_incremental_tools(&model_info.slug, instructions) + ModelInstructionsState::for_incremental_tools( + &model_info.slug, + instructions, + turn_context + .config + .features + .enabled(Feature::SuppressModelChangeAnnouncements), + ) } else { let model_instructions = render_current_instructions(); let base_instructions = self.get_prompt_base_instructions().await.text; diff --git a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs index 3164cf537a..3fd730d19e 100644 --- a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs +++ b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs @@ -148,11 +148,13 @@ async fn prediction_fork_keeps_inherited_tools() -> Result<()> { Ok(()) } -#[test_case(true; "responses_lite")] -#[test_case(false; "responses_api")] +#[test_case(true, None; "responses_lite")] +#[test_case(true, Some(true); "announcements_suppressed")] +#[test_case(false, None; "responses_api")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn incremental_tools_append_changed_catalog_without_rewriting_history( use_responses_lite: bool, + suppress_model_change_announcements: Option, ) -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -199,6 +201,12 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history( } }) .with_config(move |config| { + if let Some(suppress) = suppress_model_change_announcements { + config + .features + .set_enabled(Feature::SuppressModelChangeAnnouncements, suppress) + .expect("configure model announcements"); + } if !use_responses_lite { config.base_instructions = Some("Use the available tools to help the user.".to_string()); @@ -368,13 +376,17 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history( ); } insta::assert_snapshot!( - if use_responses_lite { + if suppress_model_change_announcements == Some(true) { + "incremental_tools_suppressed_announcements" + } else if use_responses_lite { "incremental_tools" } else { "incremental_tools_responses_api" }, context_snapshot::format_request_history_snapshot( - if use_responses_lite { + if suppress_model_change_announcements == Some(true) { + "A model switch suppresses the model ID announcement while appending changed tools and replacement instructions. Repeated turns preserve the prefix; compaction starts a new window with the updated base." + } else if use_responses_lite { "Tool definitions precede the initial base instructions. A model switch appends one developer notice, changed tools, and replacement instructions without rewriting the prefix; compaction starts a new window with the updated base." } else { "Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests." diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools_suppressed_announcements.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools_suppressed_announcements.snap new file mode 100644 index 0000000000..355ef2eef9 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools_suppressed_announcements.snap @@ -0,0 +1,113 @@ +--- +source: core/tests/suite/scenarios_incremental_tools.rs +expression: "context_snapshot::format_request_history_snapshot(if\nsuppress_model_change_announcements == Some(true)\n{\n \"A model switch suppresses the model ID announcement while appending changed tools and replacement instructions. Repeated turns preserve the prefix; compaction starts a new window with the updated base.\"\n} else if use_responses_lite\n{\n \"Tool definitions precede the initial base instructions. A model switch appends one developer notice, changed tools, and replacement instructions without rewriting the prefix; compaction starts a new window with the updated base.\"\n} else\n{\n \"Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests.\"\n}, &requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: A model switch suppresses the model ID announcement while appending changed tools and replacement instructions. Repeated turns preserve the prefix; compaction starts a new window with the updated base. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.4" + parallel_tool_calls: false + prompt_cache_key: "" + reasoning: {"context":"all_turns","effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" +-- request 1 (turn) -- +00:additional_tools/developer (2; hash=49f981f03ae5c5ce): + - namespace/functions; hash=cc6e8cf0e5405f3a + - custom/exec + - function/wait + - function/exec_command + - function/write_stdin + - function/request_user_input + - custom/apply_patch + - function/view_image + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 +01:message/developer: + +02:message/developer: + +03:message/user: + +04:message/user: + Begin work. +-- request 2 (turn) -- +05:message/user: + Continue with the same tools. + +## Window 2 (after request 2) +Settings: relative to window 1 + model: "updated-tools-model" +-- request 3 (turn) -- +00:additional_tools/developer (2; hash=49f981f03ae5c5ce): + - namespace/functions; hash=cc6e8cf0e5405f3a + - custom/exec + - function/wait + - function/exec_command + - function/write_stdin + - function/request_user_input + - custom/apply_patch + - function/view_image + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 +01:message/developer: + +02:message/developer: + +03:message/user: + +04:message/user: + Begin work. +05:message/user: + Continue with the same tools. +06:message/developer: + This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c] +07:additional_tools/developer (1; hash=26cd263a29587cc7): + - namespace/functions; hash=771cad2ff250d2f3 + - custom/exec +08:message/developer: + + The following instructions replace the previous base instructions: + + Review results before responding. + +09:message/user: + Continue with the updated execution instructions. +-- request 4 (turn) -- +10:message/user: + Continue with the same updated tools. +-- request 5 (compaction) -- +11:compaction_trigger + +## Window 3 (after request 5: input diverged at item 00) +Settings: same as window 2 +-- request 6 (turn) -- +00:additional_tools/developer (2; hash=1322c3a339378659): + - namespace/functions; hash=9d5d7a0adab05fef + - custom/exec + - function/wait + - function/exec_command + - function/write_stdin + - function/request_user_input + - custom/apply_patch + - function/view_image + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 +01:message/developer: + +02:message/user: + Begin work. +03:message/user: + Continue with the same tools. +04:message/user: + Continue with the updated execution instructions. +05:message/developer: + +06:message/user: + +07:message/user: + Continue with the same updated tools. +08:compaction:encrypted=true; chars=28; hash=e4a73156e0a498bc +09:message/user: + Continue after compaction. diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 5183a21a19..c91e727d69 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -295,6 +295,8 @@ pub enum Feature { DeferredToolWorldState, /// Track top-level tool definitions in world state and emit incremental context updates. IncrementalTools, + /// Suppress the model ID announcement in incremental context after a model switch. + SuppressModelChangeAnnouncements, /// Preserve inherited tool declarations in MCP-disabled prediction forks. PredictionPreserveParentTools, /// Diff CodeModeOnly exec definitions within the incremental tool catalog. @@ -1743,6 +1745,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::SuppressModelChangeAnnouncements, + key: "suppress_model_change_announcements", + stage: Stage::Stable, + default_enabled: false, + }, FeatureSpec { id: Feature::PredictionPreserveParentTools, key: "prediction_preserve_parent_tools", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index c791fb4684..4ccb68604b 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -979,3 +979,21 @@ fn retired_worktree_and_voice_config_overrides_are_ignored() { assert_eq!(features, Features::with_defaults()); } } + +#[test] +fn model_change_suppression_config_preserves_omission_and_explicit_values() { + for (input, expected) in [ + ("", false), + ("suppress_model_change_announcements = false", false), + ("suppress_model_change_announcements = true", true), + ] { + let config: FeaturesToml = toml::from_str(input).unwrap(); + let mut features = Features::with_defaults(); + features.apply_toml(&config); + assert_eq!( + features.enabled(Feature::SuppressModelChangeAnnouncements), + expected, + "{input}" + ); + } +}