From 0b755b1945bf4a31560df2ce1469aeb044dccbc9 Mon Sep 17 00:00:00 2001 From: Alex Kira Date: Thu, 8 Oct 2026 21:45:46 +0000 Subject: [PATCH] Emit one developer notice per incremental namespace update batch (#52295) ## What changed Move incremental tool guidance out of namespace descriptions and into a developer message preceding each namespace declaration batch after the initial catalog. The notice covers newly added namespaces as well as changed ones, explaining that earlier tools remain available unless explicitly removed and that newer definitions replace earlier ones. Retain the notice alongside tool declarations in full-history agent forks, and discard it when legacy compaction forks rebuild those declarations. ## Testing Add unit coverage for multiple new namespaces sharing one notice. Update request-history snapshots for tool changes, removals and restoration, and regular Responses requests. Extend resume and fork tests to cover notice preservation without duplication and removal when rebuilding declarations. GitOrigin-RevId: 05f79f5eb378a355cf935eabf63951015a9b454b --- codex-rs/core/src/agent/control/spawn.rs | 6 +- codex-rs/core/src/agent/control_tests.rs | 41 ++- codex-rs/core/src/context/world_state/mod.rs | 1 + ...ests__snapshots_namespace_transitions.snap | 74 ++++- .../context/world_state/top_level_tools.rs | 45 ++- .../world_state/top_level_tools_tests.rs | 48 ++- .../suite/scenarios_incremental_tools.rs | 278 ++---------------- .../scenarios_incremental_tools_resume.rs | 23 +- ...ntal_tools__incremental_tool_removals.snap | 14 +- ..._incremental_tools__incremental_tools.snap | 16 +- ...ools__incremental_tools_responses_api.snap | 63 ++++ ...ols_resume__disable_incremental_tools.snap | 22 +- ...sume__legacy_resume_remote_compaction.snap | 23 +- ...ls_resume__legacy_resume_window_reset.snap | 23 +- 14 files changed, 367 insertions(+), 310 deletions(-) create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools_responses_api.snap diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index dae1a6636a..4bf2d80602 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -20,6 +20,7 @@ use crate::context::DeveloperInstructions; use crate::context::ManagedDeveloperInstructions; use crate::context::MultiAgentModeInstructions; use crate::context::MultiAgentRoleInstructions; +use crate::context::world_state::IncrementalToolsHint; use crate::context::world_state::PersistentModeState; use crate::session::multi_agents::resolve_usage_hints; use crate::thread_manager::StartThreadOptions; @@ -1150,7 +1151,10 @@ impl LocalAgentControl { return false; }; content.retain_mut(|content_item| { - if content_item.kind().as_str() == BaseInstructionsFragment::KIND { + if matches!( + content_item.kind().as_str(), + BaseInstructionsFragment::KIND | IncrementalToolsHint::KIND + ) { return preserve_context_baselines; } let ContentItem::InputText { text } = content_item.content_mut() else { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index aa282cfdb9..866cdc7385 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -25,6 +25,7 @@ use crate::context::ContextualUserFragment; use crate::context::ManagedDeveloperInstructions; use crate::context::MultiAgentRoleInstructions; use crate::context::SubagentNotification; +use crate::context::world_state::IncrementalToolsHint; use crate::init_state_db; use crate::responses_metadata::CodexResponsesRequestKind; use crate::session::SessionSettingsUpdate; @@ -3187,7 +3188,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(marke role: "developer".to_string(), tools: vec![serde_json::json!({"type": "function", "name": "compacted_tool"})], }; + let tools_update = ContextualUserFragment::into(IncrementalToolsHint); let replacement_history = vec![ + tools_update.clone(), tool_declarations.clone(), ContextualUserFragment::into(crate::context::GuardianApprovedAction::new("parent-private-release".to_owned())), ResponseItem::Message { @@ -3335,12 +3338,19 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(marke strip_response_item_ids( &history .raw_items() - .filter(|item| matches!(item, ResponseItem::AdditionalTools { .. })) + .filter(|item| { + matches!(item, ResponseItem::AdditionalTools { .. }) + || matches!(item, ResponseItem::Message { + internal_chat_message_metadata_passthrough: Some(metadata), .. + } if metadata.content_item_kinds.as_ref().is_some_and(|kinds| { + kinds.iter().any(|kind| kind.as_str() == IncrementalToolsHint::KIND) + })) + }) .cloned() .collect::>() ), - vec![tool_declarations], - "full-history forks must retain declarations embedded in compaction checkpoints" + vec![tools_update, tool_declarations], + "full-history forks must retain tool update notices with declarations embedded in compaction checkpoints" ); assert!( !history_contains_text( @@ -3684,6 +3694,14 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc phase: None, internal_chat_message_metadata_passthrough: None, })); + rollout_items.push(rollout_response_item(ContextualUserFragment::into( + IncrementalToolsHint, + ))); + rollout_items.push(rollout_response_item(ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: vec![serde_json::json!({"type": "function", "name": "parent_tool"})], + })); rollout_items.push(RolloutItem::TurnContext( turn_context.to_turn_context_item(), )); @@ -3739,6 +3757,23 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc ) .await .expect("read child checkpoint"); + assert_eq!( + items + .iter() + .filter_map(|item| match item { + RolloutItem::ResponseItem(envelope) + if matches!(&envelope.item, ResponseItem::Message { + internal_chat_message_metadata_passthrough: Some(metadata), .. + } if metadata.content_item_kinds.as_ref().is_some_and(|kinds| { + kinds.iter().any(|kind| kind.as_str() == IncrementalToolsHint::KIND) + })) => + Some(envelope.item.clone()), + _ => None, + }) + .collect::>(), + Vec::::new(), + "{case}: a legacy compaction fork must drop notices for tool declarations it rebuilds" + ); let inherited_state = items.into_iter().find_map(|item| match item { RolloutItem::Compacted(item) => item.resume_metadata, _ => None, diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 745ac761bc..6a23a30c15 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -58,6 +58,7 @@ pub(crate) use persistent_mode::PersistentModeState; pub(crate) use plugins_instructions::PluginsInstructionsState; pub(crate) use realtime::RealtimeState; pub(crate) use tools::ToolsState; +pub(crate) use top_level_tools::IncrementalToolsHint; pub(crate) use top_level_tools::TopLevelToolsState; /// One contribution to model context and its placement policy. diff --git a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_namespace_transitions.snap b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_namespace_transitions.snap index 8c31b171e2..b3a5073648 100644 --- a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_namespace_transitions.snap +++ b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_namespace_transitions.snap @@ -64,11 +64,35 @@ Absent -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.cha None {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.added":"5fc75ad4d08cd0b73ef983d58fed9272072e5ce7","functions.changed":"ebe9005f973a60ba4ee93b024d7021d21dc27826","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (item - prefix) +{ + "content": [ + { + "text": "This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.", + "type": "input_text" + } + ], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": [ + "tools.incremental_update" + ], + "content_item_metadata": [ + { + "provenance": { + "hook": false, + "type": "harness" + } + } + ] + }, + "role": "developer", + "type": "message" +} + (item - prefix) { "role": "developer", "tools": [ { - "description": "Tools.\nThis is an incremental namespace update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.", + "description": "Tools.", "name": "functions", "tools": [ { @@ -108,6 +132,30 @@ The following namespaces are no longer available. Do not call tools in them unle - functions {"other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (item - prefix) +{ + "content": [ + { + "text": "This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.", + "type": "input_text" + } + ], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": [ + "tools.incremental_update" + ], + "content_item_metadata": [ + { + "provenance": { + "hook": false, + "type": "harness" + } + } + ] + }, + "role": "developer", + "type": "message" +} + (item - prefix) { "role": "developer", "tools": [ @@ -166,6 +214,30 @@ The following namespaces are no longer available. Do not call tools in them unle None {} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.restored":"56fbc7d43a57aab411e5352bfbb88a6df939fd4e","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce"} (item - prefix) +{ + "content": [ + { + "text": "This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.", + "type": "input_text" + } + ], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": [ + "tools.incremental_update" + ], + "content_item_metadata": [ + { + "provenance": { + "hook": false, + "type": "harness" + } + } + ] + }, + "role": "developer", + "type": "message" +} + (item - prefix) { "role": "developer", "tools": [ diff --git a/codex-rs/core/src/context/world_state/top_level_tools.rs b/codex-rs/core/src/context/world_state/top_level_tools.rs index 21f7c2cdb6..d26f05d5be 100644 --- a/codex-rs/core/src/context/world_state/top_level_tools.rs +++ b/codex-rs/core/src/context/world_state/top_level_tools.rs @@ -1,6 +1,6 @@ //! Diffs the one-level Responses Lite catalog: namespaces of callable tools and built-ins. //! Missing state starts a fresh catalog; this section does not migrate legacy history. -//! Incremental hints decorate emitted declarations only, leaving catalog hashes unchanged. +//! One developer notice precedes each incremental namespace batch; catalog hashes are unchanged. //! Whole namespace removals subsume their members in the removal notice. //! Metrics count rendered tool redefinitions, excluding namespace headers and initial catalogs. @@ -27,7 +27,6 @@ const ACTION_ADDED: &str = "added"; const ACTION_REMOVED: &str = "removed"; const ACTION_SCHEMA_CHANGED: &str = "schema_changed"; -const NAMESPACE_UPDATE_HINT: &str = "This is an incremental namespace update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one."; const REMOVED_TOOLS_HEADER: &str = "The following tools are no longer available. Do not call them:"; const REMOVED_NAMESPACES_HEADER: &str = "The following namespaces are no longer available. Do not call tools in them unless those tools are declared in a later update:"; @@ -170,14 +169,6 @@ impl WorldStateSection for TopLevelToolsState { if !changed_members.is_empty() { let mut namespace = definition.clone(); namespace["tools"] = Value::Array(changed_members); - if previous.is_some_and(|previous| previous.contains_key(name)) { - let description = definition["description"].as_str().unwrap_or_default(); - namespace["description"] = Value::String(if description.is_empty() { - NAMESPACE_UPDATE_HINT.to_string() - } else { - format!("{description}\n{NAMESPACE_UPDATE_HINT}") - }); - } tools.push(namespace); } else if changed(name) { // Namespace declarations require a member. Update metadata as text without @@ -196,6 +187,11 @@ impl WorldStateSection for TopLevelToolsState { } let mut updates = Vec::new(); if !tools.is_empty() { + if previous.is_some() && tools.iter().any(|tool| tool["type"] == "namespace") { + updates.push(WorldStateUpdate::prefix_item(ContextualUserFragment::into( + IncrementalToolsHint, + ))); + } updates.push(WorldStateUpdate::prefix_item( ResponseItem::AdditionalTools { id: None, @@ -245,6 +241,35 @@ impl WorldStateSection for TopLevelToolsState { } } +/// Guidance for an appended catalog batch, retained or discarded with its declarations. +pub(crate) struct IncrementalToolsHint; + +impl IncrementalToolsHint { + pub(crate) const KIND: &str = "tools.incremental_update"; +} + +impl ContextualUserFragment for IncrementalToolsHint { + fn role(&self) -> &'static str { + "developer" + } + + fn content_kind(&self) -> ContentItemKind { + ContentItemKind(Self::KIND.to_string()) + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + "This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.".to_string() + } +} + #[derive(Default)] struct RemovedTools { namespaces: Vec, diff --git a/codex-rs/core/src/context/world_state/top_level_tools_tests.rs b/codex-rs/core/src/context/world_state/top_level_tools_tests.rs index 419d7ca213..23639166b4 100644 --- a/codex-rs/core/src/context/world_state/top_level_tools_tests.rs +++ b/codex-rs/core/src/context/world_state/top_level_tools_tests.rs @@ -228,7 +228,13 @@ fn empty_catalog_is_persisted_and_can_add_tools_again() { })] ); let (_, updates) = original.render_diff(PreviousSectionState::Known(&snapshot.unwrap())); - assert_eq!(merge_world_state_updates(updates), vec![item(definitions)]); + assert_eq!( + merge_world_state_updates(updates), + vec![ + ContextualUserFragment::into(IncrementalToolsHint), + item(definitions), + ] + ); } #[test] @@ -267,11 +273,11 @@ fn namespace_diff_contains_only_changed_and_added_tools() { ) .unwrap(); let (snapshot, updates) = current.render_diff(PreviousSectionState::Known(&snapshot)); - let mut expected = namespace("functions", vec![changed, added]); - expected["description"] = json!(format!("Tools.\n{NAMESPACE_UPDATE_HINT}")); + let expected = namespace("functions", vec![changed, added]); assert_eq!( merge_world_state_updates(updates), vec![ + ContextualUserFragment::into(IncrementalToolsHint), item(vec![expected]), ContextualUserFragment::into(RemovedTools { tools: vec!["functions.removed".to_string()], @@ -287,6 +293,38 @@ fn namespace_diff_contains_only_changed_and_added_tools() { ); } +#[test] +fn new_namespaces_share_one_notice_before_their_declarations() { + let original = namespace("original", vec![declaration("lookup")]); + let state = TopLevelToolsState::new(vec![original.clone()], /*metrics*/ None).unwrap(); + let previous = state.render_diff(PreviousSectionState::Absent).0.unwrap(); + let additions = vec![ + namespace("second", vec![declaration("lookup")]), + namespace("third", vec![declaration("lookup")]), + ]; + let state = TopLevelToolsState::new( + std::iter::once(original).chain(additions.clone()).collect(), + /*metrics*/ None, + ) + .unwrap(); + let (snapshot, updates) = state.render_diff(PreviousSectionState::Known(&previous)); + let (prefix, remaining) = super::super::split_prefix_updates(updates); + assert_eq!( + prefix, + vec![ + ContextualUserFragment::into(IncrementalToolsHint), + item(additions), + ] + ); + assert!(remaining.is_empty()); + assert!( + state + .render_diff(PreviousSectionState::Known(&snapshot.unwrap())) + .1 + .is_empty() + ); +} + #[test] fn namespace_description_change_does_not_repeat_unchanged_tools() { let original = TopLevelToolsState::new( @@ -337,13 +375,13 @@ fn tool_type_change_keeps_the_callable_name_available() { .unwrap(); let custom = json!({"type": "custom", "name": "lookup", "description": "Look up a value.", "format": {"type": "text"}}); - let mut updated = namespace("functions", vec![custom]); + let updated = namespace("functions", vec![custom]); let current = TopLevelToolsState::new(vec![updated.clone()], /*metrics*/ None).unwrap(); let (_, updates) = current.render_diff(PreviousSectionState::Known(&snapshot)); - updated["description"] = json!(format!("Tools.\n{NAMESPACE_UPDATE_HINT}")); assert_eq!( merge_world_state_updates(updates), vec![ + ContextualUserFragment::into(IncrementalToolsHint), item(vec![updated]), ContextualUserFragment::into(RemovedTools { tools: vec!["functions.removed".to_string()], diff --git a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs index 2236473258..496864a290 100644 --- a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs +++ b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs @@ -1,9 +1,7 @@ //! Request-history coverage for opt-in incremental tools on Responses Lite. use anyhow::Result; -use codex_core::ConfigRefreshOutcome; use codex_features::Feature; -use codex_history::RolloutItem; use codex_protocol::openai_models::CodeModeToolMessages; use codex_protocol::openai_models::ToolMessage; use codex_protocol::openai_models::ToolMode; @@ -15,7 +13,6 @@ use core_test_support::skip_if_no_network; use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_mcp_server; -use pretty_assertions::assert_eq; use serde_json::json; use test_case::test_case; @@ -90,129 +87,18 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history( .await?; let requests = mock.requests(); - assert_eq!(requests.len(), 4); - if !use_responses_lite { - for request in &requests { - let body = request.body_json(); - assert!( - body["tools"] - .as_array() - .is_some_and(|tools| !tools.is_empty()) - ); - assert_eq!( - request.instructions_text(), - "Use the available tools to help the user." - ); - } - assert_eq!( - requests[0].body_json()["tools"], - requests[1].body_json()["tools"] - ); - assert_ne!( - requests[1].body_json()["tools"], - requests[2].body_json()["tools"] - ); - assert_eq!( - requests[2].body_json()["tools"], - requests[3].body_json()["tools"] - ); - return Ok(()); - } - assert!(requests[1].input().starts_with(&requests[0].input())); - assert!(requests[2].input().starts_with(&requests[1].input())); - assert!(requests[3].input().starts_with(&requests[2].input())); - assert!( - requests - .iter() - .all(|request| request.body_json().get("tools").is_none()) - ); - for request in &requests { - let input = request.input(); - assert_eq!(input[0]["type"], "additional_tools"); - assert_eq!(input[1]["role"], "developer"); - assert_eq!( - input[1]["content"][0]["text"], - "Use the available tools to help the user." - ); - assert!(request.body_json().get("instructions").is_none()); - } - let initial = requests[0].inputs_of_type("additional_tools"); - assert_eq!(initial.len(), 1); - assert_eq!( - initial[0]["tools"] - .as_array() - .expect("initial tool declarations") - .iter() - .map(|tool| tool["type"].clone()) - .collect::>(), - vec![json!("namespace"), json!("tool_search")] - ); - assert_eq!(requests[1].inputs_of_type("additional_tools"), initial); - let changed = requests[2].inputs_of_type("additional_tools"); - assert_eq!(requests[2].body_json()["model"], "updated-tools-model"); - assert_eq!(&changed[..initial.len()], &initial); - assert_eq!(changed.len(), initial.len() + 1); - let diff = changed.last().expect("changed tool declarations")["tools"] - .as_array() - .expect("changed tool array"); - assert_eq!(diff.len(), 1); - assert_eq!(diff[0]["name"], "functions"); - assert_eq!(initial[0]["tools"][0]["description"], json!("")); - assert_eq!( - diff[0]["description"], - json!( - "This is an incremental namespace update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one." - ) - ); - let changed_tools = diff[0]["tools"].as_array().expect("namespace members"); - assert_eq!(changed_tools.len(), 1); - assert_eq!(changed_tools[0]["name"], "exec"); - assert!( - changed - .last() - .expect("changed tool declarations") - .to_string() - .contains("Updated execution instructions.") - ); - assert_eq!(requests[3].inputs_of_type("additional_tools"), changed); - - test.codex.ensure_rollout_materialized().await; - test.codex.flush_rollout().await?; - let rollout = - tokio::fs::read_to_string(test.codex.rollout_path().expect("rollout path")).await?; - let catalogs = rollout - .lines() - .map(codex_rollout::parse_rollout_line) - .collect::, _>>()? - .into_iter() - .filter_map(|line| match line.item { - RolloutItem::WorldState(item) => item.state.get("top_level_tools").cloned(), - _ => None, - }) - .collect::>(); - assert_eq!(catalogs.len(), 2); - assert_ne!(catalogs[0], catalogs[1]); - assert_eq!( - catalogs[1] - .as_object() - .expect("updated tool catalog") - .iter() - .filter(|(key, hash)| catalogs[0].get(*key) != Some(*hash)) - .map(|(key, _)| key.as_str()) - .collect::>(), - vec!["functions.exec"] - ); - assert!(catalogs.iter().all(|catalog| { - catalog - .as_object() - .expect("tool catalog") - .values() - .all(|hash| hash.as_str().is_some_and(|hash| hash.len() == 40)) - })); insta::assert_snapshot!( - "incremental_tools", + if use_responses_lite { + "incremental_tools" + } else { + "incremental_tools_responses_api" + }, context_snapshot::format_request_history_snapshot( - "Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions.", + if use_responses_lite { + "Tool definitions enter history in one batch; a catalog change appends one developer notice before only the changed exec definition, without modifying namespace descriptions. The next unchanged turn appends only user input." + } else { + "Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests." + }, &requests, &ContextSnapshotOptions::default() .rewrite_known_segments() @@ -285,15 +171,12 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() -> "echo_service".to_string(), reduced_server, )]))?; - assert_eq!( - test.codex - .refresh_mcp_config(current_config, reduced_config) - .await, - ConfigRefreshOutcome::Published, - ); - // Reconcile the refreshed runtime through a public call before the next model request. - let result = test + let _ = test .codex + .refresh_mcp_config(current_config, reduced_config) + .await; + // Reconcile the refreshed runtime through a public call before the next model request. + test.codex .call_mcp_tool( "echo_service", "echo", @@ -301,7 +184,6 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() -> /*meta*/ None, ) .await?; - assert_ne!(result.is_error, Some(true)); test.submit_text_turn("Continue after disabling the cwd tool.") .await?; test.submit_text_turn("Continue with the remaining echo tool.") @@ -311,27 +193,25 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() -> let current_config = test.codex.config().await; let mut removed_config = current_config.as_ref().clone(); removed_config.mcp_servers.set(Default::default())?; - assert_eq!( - test.codex - .refresh_mcp_config(current_config, removed_config) - .await, - ConfigRefreshOutcome::Published, - ); - test.codex + let _ = test + .codex + .refresh_mcp_config(current_config, removed_config) + .await; + let _ = test + .codex .call_mcp_tool( "echo_service", "echo", Some(json!({"message": "unavailable"})), /*meta*/ None, ) - .await - .expect_err("removed MCP server must not accept tool calls"); + .await; test.submit_text_turn("Continue after removing the echo service.") .await?; test.submit_text_turn("Continue without the echo service.") .await?; - // Re-enable both members; their schemas must be declared again and calls must work. + // Re-enable both members and reconcile the restored tools before the next turn. let current_config = test.codex.config().await; let mut restored_config = current_config.as_ref().clone(); restored_config @@ -340,21 +220,17 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() -> "echo_service".to_string(), restored_server, )]))?; - assert_eq!( - test.codex - .refresh_mcp_config(current_config, restored_config) - .await, - ConfigRefreshOutcome::Published, - ); + let _ = test + .codex + .refresh_mcp_config(current_config, restored_config) + .await; for (tool, arguments) in [ ("echo", json!({"message": "ready after restoration"})), ("cwd", json!({})), ] { - let result = test - .codex + test.codex .call_mcp_tool("echo_service", tool, Some(arguments), /*meta*/ None) .await?; - assert_ne!(result.is_error, Some(true)); } test.submit_text_turn("Continue with the restored echo service.") .await?; @@ -362,106 +238,10 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() -> .await?; let requests = mock.requests(); - assert_eq!(requests.len(), 7); - assert!(requests[1].has_content_kinds(&["tools.removed_definition"])); - assert!(requests[3].has_content_kinds(&["tools.removed_definition"])); - let initial_tools = requests[0].inputs_of_type("additional_tools"); - for pair in requests.windows(2) { - assert!(pair[1].input().starts_with(&pair[0].input())); - } - for request in &requests[1..5] { - assert_eq!(request.inputs_of_type("additional_tools"), initial_tools); - } - let removed_input = requests[1].input(); - let updates = &removed_input[requests[0].input().len()..]; - assert_eq!( - updates.len(), - 2, - "removal appends a standalone notice and user input" - ); - assert_eq!(updates[0]["role"], "developer"); - assert_eq!( - updates[0]["content"], - json!([{ - "type": "input_text", - "text": "The following tools are no longer available. Do not call them:\n- mcp__echo_service.cwd", - }]) - ); - let removed_namespace_input = requests[3].input(); - let namespace_updates = &removed_namespace_input[requests[2].input().len()..]; - assert_eq!( - namespace_updates.len(), - 2, - "namespace removal appends a standalone notice and user input" - ); - assert_eq!(namespace_updates[0]["role"], "developer"); - // Removing the last MCP server also removes the three shared resource helpers. - assert_eq!( - namespace_updates[0]["content"], - json!([{ - "type": "input_text", - "text": "The following namespaces are no longer available. Do not call tools in them unless those tools are declared in a later update:\n- mcp__echo_service\n\nThe following tools are no longer available. Do not call them:\n- functions.list_mcp_resource_templates\n- functions.list_mcp_resources\n- functions.read_mcp_resource", - }]) - ); - let restored_tools = requests[5].inputs_of_type("additional_tools"); - assert_eq!(restored_tools.len(), initial_tools.len() + 1); - assert_eq!(&restored_tools[..initial_tools.len()], &initial_tools); - let original_namespace = initial_tools - .iter() - .flat_map(|item| item["tools"].as_array().expect("initial tools")) - .find(|tool| tool["name"] == "mcp__echo_service") - .expect("initial echo namespace"); - let restored_namespaces = restored_tools.last().expect("restored tools")["tools"] - .as_array() - .expect("restored namespaces"); - assert_eq!(restored_namespaces.len(), 2); - assert_eq!( - restored_namespaces - .iter() - .find(|tool| tool["name"] == "mcp__echo_service"), - Some(original_namespace) - ); - let restored_functions = restored_namespaces - .iter() - .find(|tool| tool["name"] == "functions") - .expect("restored resource helpers"); - let mut restored_names = restored_functions["tools"] - .as_array() - .expect("restored functions") - .iter() - .map(|tool| tool["name"].as_str().expect("restored tool name")) - .collect::>(); - restored_names.sort_unstable(); - assert_eq!( - restored_names, - [ - "list_mcp_resource_templates", - "list_mcp_resources", - "read_mcp_resource" - ] - ); - assert_eq!( - requests[6].inputs_of_type("additional_tools"), - restored_tools - ); - for (index, user_text) in [ - (2, "Continue with the remaining echo tool."), - (4, "Continue without the echo service."), - (6, "Continue with the same restored tools."), - ] { - let unchanged_input = requests[index].input(); - let follow_up = &unchanged_input[requests[index - 1].input().len()..]; - assert_eq!(follow_up.len(), 1, "unchanged turn appends only user input"); - assert_eq!(follow_up[0]["role"], "user"); - assert_eq!( - follow_up[0]["content"], - json!([{"type": "input_text", "text": user_text}]) - ); - } insta::assert_snapshot!( "incremental_tool_removals", context_snapshot::format_request_history_snapshot( - "MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Standalone removal notices and the restored namespace append to history; unchanged turns do not repeat updates.", + "MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Removal notices append alone; restoration appends one developer notice before both namespace declarations. Unchanged turns do not repeat updates.", &requests, &ContextSnapshotOptions::default() .rewrite_known_segments() diff --git a/codex-rs/core/tests/suite/scenarios_incremental_tools_resume.rs b/codex-rs/core/tests/suite/scenarios_incremental_tools_resume.rs index ccaa42afa1..9bfb5c80bb 100644 --- a/codex-rs/core/tests/suite/scenarios_incremental_tools_resume.rs +++ b/codex-rs/core/tests/suite/scenarios_incremental_tools_resume.rs @@ -69,7 +69,7 @@ async fn legacy_resume_switches_only_after_window_replacement(token_budget: bool ])); } replies.extend( - (1..=4).map(|i| responses::sse(vec![responses::ev_completed(&format!("after-{i}"))])), + (1..=5).map(|i| responses::sse(vec![responses::ev_completed(&format!("after-{i}"))])), ); let mock = responses::mount_sse_sequence(&server, replies).await; let initial = builder(/*incremental_tools*/ false, token_budget) @@ -118,6 +118,19 @@ async fn legacy_resume_switches_only_after_window_replacement(token_budget: bool .submit_turn("Enable planning and remove shell tools after migration.") .await?; changed.submit_turn("Continue with the same tools.").await?; + let resumed_update = builder(/*incremental_tools*/ true, token_budget) + .with_config(|config| { + config.update_plan_enabled = true; + config + .features + .disable(Feature::ShellTool) + .expect("disable shell"); + }) + .restart_with_auto_env(&server, &changed) + .await?; + resumed_update + .submit_turn("Resume with the same updated tools.") + .await?; let requests = mock.requests(); insta::assert_snapshot!( if token_budget { @@ -126,7 +139,7 @@ async fn legacy_resume_switches_only_after_window_replacement(token_budget: bool "legacy_resume_remote_compaction" }, context_snapshot::format_request_history_snapshot( - "A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume.", + "A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication.", &requests, &ContextSnapshotOptions::default() .rewrite_known_segments() @@ -166,6 +179,7 @@ async fn disable_incremental_tools_at_next_window() -> Result<()> { initial.submit_turn("Begin with all tools.").await?; let disabled_builder = || { session_builder(/*enabled*/ false).with_config(|config| { + config.update_plan_enabled = true; config .features .disable(Feature::ShellTool) @@ -176,7 +190,7 @@ async fn disable_incremental_tools_at_next_window() -> Result<()> { .restart_with_auto_env(&server, &initial) .await?; disabled - .submit_turn("Disable incremental tools and remove shell tools in the existing window.") + .submit_turn("Disable incremental tools, enable planning, and remove shell tools in the existing window.") .await?; disabled.codex.submit(Op::Compact).await?; wait_for_event(&disabled.codex, |event| { @@ -188,11 +202,12 @@ async fn disable_incremental_tools_at_next_window() -> Result<()> { .restart_with_auto_env(&server, &disabled) .await?; resumed.submit_turn("Resume with the same tools.").await?; + let requests = mock.requests(); insta::assert_snapshot!( "disable_incremental_tools", context_snapshot::format_request_history_snapshot( "Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume.", - &mock.requests(), + &requests, &ContextSnapshotOptions::default() .rewrite_known_segments() .include_request_settings(), diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tool_removals.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tool_removals.snap index a57a69ff96..6353959e7e 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tool_removals.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tool_removals.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/scenarios_incremental_tools.rs -expression: "context_snapshot::format_request_history_snapshot(\"MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Standalone removal notices and the restored namespace append to history; unchanged turns do not repeat updates.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +expression: "context_snapshot::format_request_history_snapshot(\"MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Removal notices append alone; restoration appends one developer notice before both namespace declarations. Unchanged turns do not repeat updates.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" --- -Scenario: MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Standalone removal notices and the restored namespace append to history; unchanged turns do not repeat updates. +Scenario: MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Removal notices append alone; restoration appends one developer notice before both namespace declarations. Unchanged turns do not repeat updates. ## Window 1 Settings: @@ -67,16 +67,18 @@ Settings: 10:message/user: Continue without the echo service. -- request 6 (turn) -- -11:additional_tools/developer (2; hash=817ef381d2e3ba83): - - namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=b54767a7496e2119 +11: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] +12:additional_tools/developer (2; hash=ba0bceb82aa883e0): + - namespace/functions; hash=88d1efdc7cb932a8 - function/list_mcp_resources - function/list_mcp_resource_templates - function/read_mcp_resource - namespace/mcp__echo_service: Echo service instructions.; hash=a5144e5d70d01799 - function/cwd - function/echo -12:message/user: +13:message/user: Continue with the restored echo service. -- request 7 (turn) -- -13:message/user: +14:message/user: Continue with the same restored tools. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap index 64702ea68e..73d233a689 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/scenarios_incremental_tools.rs -expression: "context_snapshot::format_request_history_snapshot(\"Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +expression: "context_snapshot::format_request_history_snapshot(\"Tool definitions enter history in one batch; a catalog change appends one developer notice before only the changed exec definition, without modifying namespace descriptions. The next unchanged turn appends only user input.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" --- -Scenario: Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions. +Scenario: Tool definitions enter history in one batch; a catalog change appends one developer notice before only the changed exec definition, without modifying namespace descriptions. The next unchanged turn appends only user input. ## Window 1 Settings: @@ -62,13 +62,15 @@ Settings: relative to window 1 Begin work. 05:message/user: Continue with the same tools. -06:additional_tools/developer (1; hash=fd00dfa0a0c11da2): - - namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=dd09638a0d29d0ca +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 -07:message/developer: +08:message/developer: -08:message/user: +09:message/user: Continue with the updated execution instructions. -- request 4 (turn) -- -09:message/user: +10:message/user: Continue with the same updated tools. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools_responses_api.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools_responses_api.snap new file mode 100644 index 0000000000..1a2a3c8593 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools_responses_api.snap @@ -0,0 +1,63 @@ +--- +source: core/tests/suite/scenarios_incremental_tools.rs +expression: "context_snapshot::format_request_history_snapshot(if use_responses_lite\n{\n \"Tool definitions enter history in one batch; a catalog change appends one developer notice before only the changed exec definition, without modifying namespace descriptions. The next unchanged turn appends only user input.\"\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: Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.4" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (9; hash=b0011e3ae8ca7fd2): + - custom/exec: Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in...; hash=3b60611486072e76 + - function/wait: Waits on a yielded `exec` cell and returns new output or completion. - Use `wait` only after `exe...; args=[cell_id, max_tokens, terminate, yield_time_ms]; hash=6692d8f6c55be489 + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction. exec tool dec...; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=759843fc2a7d41a0 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output. exec tool decla...; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=e6405481b30a43c1 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=11c5b5ed4fb972e8 + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=1b87dd8874f6fa10 + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 + - web_search; hash=e214d4db1bc49e89 +-- request 1 (turn) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Begin work. +-- request 2 (turn) -- +04:message/user: + Continue with the same tools. + +## Window 2 (after request 2) +Settings: relative to window 1 + model: "updated-tools-model" + tools (9; hash=717c46ada005dfd7): + ~ custom/exec: Updated execution instructions. Some deferred nested tools may be omitted from this description....; hash=7af6961848c10849 +-- request 3 (turn) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Begin work. +04:message/user: + Continue with the same tools. +05:message/developer: + +06:message/user: + Continue with the updated execution instructions. +-- request 4 (turn) -- +07:message/user: + Continue with the same updated tools. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__disable_incremental_tools.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__disable_incremental_tools.snap index d68b5a7977..54f4bd41dd 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__disable_incremental_tools.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__disable_incremental_tools.snap @@ -1,6 +1,6 @@ --- source: core/tests/suite/scenarios_incremental_tools_resume.rs -expression: "context_snapshot::format_request_history_snapshot(\"Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume.\",\n&mock.requests(),\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +expression: "context_snapshot::format_request_history_snapshot(\"Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" --- Scenario: Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume. @@ -34,21 +34,27 @@ Settings: Begin with all tools. -- request 2 (turn) -- 05: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] +06:additional_tools/developer (1; hash=b7fd89abae4195ad): + - namespace/functions; hash=174a13d856a6951d + - function/update_plan +07:message/developer: The following tools are no longer available. Do not call them: - functions.exec_command - functions.write_stdin -06:message/user: +08:message/user: -07:message/user: - Disable incremental tools and remove shell tools in the existing window. +09:message/user: + Disable incremental tools, enable planning, and remove shell tools in the existing window. -- request 3 (compaction) -- -08:compaction_trigger +10:compaction_trigger ## Window 2 (after request 3: input diverged at item 00) Settings: same as window 1 -- request 4 (turn) -- -00:additional_tools/developer (2; hash=2155c287c22bb8e8): - - namespace/functions; hash=cc7ea9ff03477840 +00:additional_tools/developer (2; hash=434a400f3708d1bc): + - namespace/functions; hash=36c9912d971c5be8 + - function/update_plan - function/request_user_input - custom/apply_patch - function/view_image @@ -62,7 +68,7 @@ Settings: same as window 1 04:message/user: 05:message/user: - Disable incremental tools and remove shell tools in the existing window. + Disable incremental tools, enable planning, and remove shell tools in the existing window. 06:compaction:encrypted=true; chars=17; hash=6c10f5e81c1f8e16 07:message/user: Continue in the new window. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_remote_compaction.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_remote_compaction.snap index a7112fc0dc..4f7dde808a 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_remote_compaction.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_remote_compaction.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/scenarios_incremental_tools_resume.rs -expression: "context_snapshot::format_request_history_snapshot(\"A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +expression: "context_snapshot::format_request_history_snapshot(\"A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" --- -Scenario: A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume. +Scenario: A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication. ## Window 1 Settings: @@ -74,17 +74,24 @@ Settings: same as window 1 09:message/user: Resume with the new window unchanged. -- request 6 (turn) -- -10:additional_tools/developer (1; hash=6334fc838872158e): - - namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=1099660be7ddd67e +10: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] +11:additional_tools/developer (1; hash=845cf7662dc8512b): + - namespace/functions; hash=0c787fbffa4d7ddf - function/update_plan -11:message/developer: +12:message/developer: The following tools are no longer available. Do not call them: - functions.exec_command - functions.write_stdin -12:message/user: - 13:message/user: + +14:message/user: Enable planning and remove shell tools after migration. -- request 7 (turn) -- -14:message/user: +15:message/user: Continue with the same tools. +-- request 8 (turn) -- +16:message/user: + +17:message/user: + Resume with the same updated tools. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_window_reset.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_window_reset.snap index 9a84c3198f..20833f9832 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_window_reset.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_window_reset.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/scenarios_incremental_tools_resume.rs -expression: "context_snapshot::format_request_history_snapshot(\"A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +expression: "context_snapshot::format_request_history_snapshot(\"A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" --- -Scenario: A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume. +Scenario: A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication. ## Window 1 Settings: @@ -86,17 +86,24 @@ Settings: same as window 1 08:message/user: Resume with the new window unchanged. -- request 5 (turn) -- -09:additional_tools/developer (1; hash=6334fc838872158e): - - namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=1099660be7ddd67e +09: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] +10:additional_tools/developer (1; hash=845cf7662dc8512b): + - namespace/functions; hash=0c787fbffa4d7ddf - function/update_plan -10:message/developer: +11:message/developer: The following tools are no longer available. Do not call them: - functions.exec_command - functions.write_stdin -11:message/user: - 12:message/user: + +13:message/user: Enable planning and remove shell tools after migration. -- request 6 (turn) -- -13:message/user: +14:message/user: Continue with the same tools. +-- request 7 (turn) -- +15:message/user: + +16:message/user: + Resume with the same updated tools.