From e79c498b5e44352eb1543d5631bdaec0a57bae58 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 6 Oct 2026 19:16:38 +0000 Subject: [PATCH] Preserve tool declaration mode across resumed context windows (#51480) ## Why Changing the incremental tools setting on resume should not move tool declarations and base instructions within an existing context window. ## What changed - Determine the current window's tool declaration mode from its history, and apply the incremental tools setting when building a new window after compaction or a context reset. - Use that mode consistently for sampling, remote compaction, startup prewarm, prompt debugging, and Guardian input budgeting. - Preserve tool declarations alongside their comparison baseline in full-history agent forks, including compacted histories; remove them together when rebuilding context. ## Testing Add request snapshots covering legacy resume, migration after remote compaction or a context reset, disabling incremental tools, and tool updates after restart. Extend agent fork and WebSocket startup prewarm tests to cover retained declarations and incremental tools. GitOrigin-RevId: 7c46849a1788027a8435082e4d1f7e7f48d2700c --- codex-rs/core/src/agent/control/spawn.rs | 17 +- codex-rs/core/src/agent/control_tests.rs | 46 +++- .../core/src/compact_remote_v2_attempt.rs | 8 +- codex-rs/core/src/compact_tests.rs | 6 +- .../core/src/context/base_instructions.rs | 2 +- codex-rs/core/src/context_manager/history.rs | 5 + codex-rs/core/src/guardian/input_budget.rs | 1 + .../core/src/guardian/input_budget_tests.rs | 7 +- codex-rs/core/src/prompt_debug.rs | 8 +- codex-rs/core/src/session/mod.rs | 30 +-- codex-rs/core/src/session/startup_prewarm.rs | 3 + .../core/src/session/step_activation_tests.rs | 8 +- codex-rs/core/src/session/step_context.rs | 3 +- codex-rs/core/src/session/tests.rs | 37 +++- .../core/src/session/tests/guardian_tests.rs | 2 +- codex-rs/core/src/session/turn.rs | 38 ++-- codex-rs/core/src/session/world_state.rs | 23 +- codex-rs/core/src/tasks/compact.rs | 6 +- .../src/tools/handlers/multi_agents_tests.rs | 3 +- codex-rs/core/tests/common/test_codex.rs | 24 +++ codex-rs/core/tests/suite/agent_websocket.rs | 46 +++- codex-rs/core/tests/suite/scenarios.rs | 2 + .../scenarios_incremental_tools_resume.rs | 202 ++++++++++++++++++ ...ols_resume__disable_incremental_tools.snap | 73 +++++++ ...sume__legacy_resume_remote_compaction.snap | 90 ++++++++ ...ls_resume__legacy_resume_window_reset.snap | 102 +++++++++ codex-rs/protocol/src/models/item_metadata.rs | 6 + 27 files changed, 723 insertions(+), 75 deletions(-) create mode 100644 codex-rs/core/tests/suite/scenarios_incremental_tools_resume.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__disable_incremental_tools.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_remote_compaction.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_window_reset.snap diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 7492562e33..20e28a8bfe 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -12,6 +12,7 @@ use crate::agents_md_manager::SessionInstructions; use crate::codex_thread::CodexThread; use crate::codex_thread::ThreadConfigSnapshot; use crate::config::PermissionProfileSnapshot; +use crate::context::BaseInstructionsFragment; use crate::context::ContextualUserFragment; use crate::context::CurrentTimeReminder; use crate::context::CurrentTimeUnavailable; @@ -95,10 +96,10 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_context_baselines: bool ), _ => false, }, - ResponseItem::FunctionCallOutput { call_id: None, .. } - | ResponseItem::ConfigurationUpdate { .. } => true, ResponseItem::AdditionalTools { .. } - | ResponseItem::AgentMessage { .. } + | ResponseItem::FunctionCallOutput { call_id: None, .. } + | ResponseItem::ConfigurationUpdate { .. } => true, + ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -141,7 +142,7 @@ fn retain_forked_developer_message(item: &mut ResponseItem, usage_hint_texts: &[ content.retain(|content_item| { // Persisted role hints can predate the current bundled wording and lack markers. if matches!( - content_item.kind().0.as_str(), + content_item.kind().as_str(), "guardian.approved_action" | "multi_agent.role_instructions" | "multi_agent.usage_hint" ) { return false; @@ -1141,6 +1142,11 @@ impl LocalAgentControl { metadata.user_input_order = None; } let response_item = &mut envelope.item; + // Tool declarations and their comparison baseline must survive or be rebuilt together. + // Apply this to both standalone items and compaction replacement histories. + if matches!(response_item, ResponseItem::AdditionalTools { .. }) { + return preserve_context_baselines; + } if matches!(response_item, ResponseItem::AgentMessage { .. }) { return false; } @@ -1156,6 +1162,9 @@ impl LocalAgentControl { return false; }; content.retain_mut(|content_item| { + if content_item.kind().as_str() == BaseInstructionsFragment::KIND { + return preserve_context_baselines; + } let ContentItem::InputText { text } = content_item.content_mut() else { return true; }; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 012df7aab8..976af7fa37 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -88,6 +88,7 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::WorldStateItem; use codex_thread_store::ArchiveThreadParams; use codex_thread_store::InMemoryThreadStore; use codex_thread_store::LocalThreadStore; @@ -468,6 +469,10 @@ async fn mcp_attribution_in_constructed_request(thread: &CodexThread) -> McpAttr .for_prompt(&step_context.settings.model_info.input_modalities), &step_context, thread.session.get_prompt_base_instructions().await, + thread + .session + .current_window_uses_incremental_tools(&step_context) + .await, ); let metadata = thread .session @@ -2528,6 +2533,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { "parent trigger message".to_string(), /*trigger_turn*/ true, ); + let tool_declarations = ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: vec![serde_json::json!({"type": "function", "name": "parent_tool"})], + }; + let tool_state = serde_json::json!({"top_level_tools": {"function:parent_tool": "hash"}}) + .as_object() + .unwrap() + .clone(); let standalone_output = ResponseItem::FunctionCallOutput { id: None, call_id: None, @@ -2608,6 +2622,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { internal_chat_message_metadata_passthrough: None, }, trigger_message.to_response_input_item().into(), + tool_declarations.clone(), spawn_agent_call(&parent_spawn_call_id), ], ) @@ -2623,9 +2638,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let parent_reference_context_item = turn_context.to_turn_context_item(); parent_thread .session - .persist_rollout_items(&[RolloutItem::TurnContext( - parent_reference_context_item.clone(), - )]) + .persist_rollout_items(&[ + RolloutItem::WorldState(WorldStateItem::full(tool_state.clone())), + RolloutItem::TurnContext(parent_reference_context_item.clone()), + ]) .await; parent_thread .session @@ -2754,6 +2770,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { expected_partial_answer, expected_final_answer, expected_standalone_output, + tool_declarations, ContextualUserFragment::into(MultiAgentRoleInstructions::Configured( "Child subagent guidance.".to_string(), )), @@ -2771,6 +2788,12 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { "full-history forked child should preserve the parent diff baseline" ); + assert_eq!( + history.world_state_checkpoint().unwrap().state, + tool_state, + "full-history forks must retain the catalog baseline with its declarations" + ); + let mut no_hint_child_config = harness.config.clone(); let _ = no_hint_child_config.features.enable(Feature::MultiAgentV2); no_hint_child_config.developer_instructions = Some(String::new()); @@ -2900,7 +2923,13 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(marke "compacted parent delegated task".to_string(), /*trigger_turn*/ true, ); + let tool_declarations = ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: vec![serde_json::json!({"type": "function", "name": "compacted_tool"})], + }; let replacement_history = vec![ + tool_declarations.clone(), ContextualUserFragment::into(crate::context::GuardianApprovedAction::new("parent-private-release".to_owned())), ResponseItem::Message { id: None, @@ -3043,6 +3072,17 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(marke .await .expect("child thread should be registered"); let history = child_thread.session.clone_history().await; + assert_eq!( + strip_response_item_ids( + &history + .raw_items() + .filter(|item| matches!(item, ResponseItem::AdditionalTools { .. })) + .cloned() + .collect::>() + ), + vec![tool_declarations], + "full-history forks must retain declarations embedded in compaction checkpoints" + ); assert!( !history_contains_text( history.conversation_history_snapshot().review_items(), diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs index 3ce092268b..4df467edf4 100644 --- a/codex-rs/core/src/compact_remote_v2_attempt.rs +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -43,7 +43,11 @@ pub(super) async fn run_remote_compact_v2_attempt( let turn_context = &step_context.turn; let mut history = sess.clone_history().await; let input_goal_ids = UserGoalUpdate::message_ids(history.raw_items()); - let base_instructions = if step_context.uses_incremental_tools() { + let tools_in_history = step_context.settings.model_info.use_responses_lite + && history + .raw_items() + .any(|item| matches!(item, ResponseItem::AdditionalTools { .. })); + let base_instructions = if tools_in_history { BaseInstructions { text: String::new(), provenance: None, @@ -91,7 +95,7 @@ pub(super) async fn run_remote_compact_v2_attempt( input.push(ResponseItem::CompactionTrigger {}); let prompt = Prompt { input, - tools: if step_context.uses_incremental_tools() { + tools: if tools_in_history { Arc::default() } else { tool_router.model_visible_specs() diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index f57bd8853a..28051a77c3 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -160,7 +160,11 @@ async fn local_compaction_respects_tool_metadata_state( let step = session .capture_step_context(turn, &tokio_util::sync::CancellationToken::new()) .await?; - let world_state = Arc::new(session.build_world_state_for_step(&step).await?); + let world_state = Arc::new( + session + .build_world_state_for_step(&step, /*new_window*/ true) + .await?, + ); // OpenAI identity keeps the client from removing passthrough for compatibility. run_compact_task( Arc::clone(&session), diff --git a/codex-rs/core/src/context/base_instructions.rs b/codex-rs/core/src/context/base_instructions.rs index d691f689cf..6ef7d583c6 100644 --- a/codex-rs/core/src/context/base_instructions.rs +++ b/codex-rs/core/src/context/base_instructions.rs @@ -19,7 +19,7 @@ impl BaseInstructionsFragment { metadata .content_item_kinds .as_ref() - .is_some_and(|kinds| kinds.iter().any(|kind| kind.0 == Self::KIND)) + .is_some_and(|kinds| kinds.iter().any(|kind| kind.as_str() == Self::KIND)) } } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 1c32e5e2b9..38ef4a59c0 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -620,6 +620,11 @@ impl ContextManager { self.items.iter().map(|envelope| &envelope.item) } + pub(crate) fn has_tool_declarations(&self) -> bool { + self.raw_items() + .any(|item| matches!(item, ResponseItem::AdditionalTools { .. })) + } + /// Returns annotated history items without cloning their response payloads. pub(crate) fn annotated_items(&self) -> &[ResponseItemEnvelope] { &self.items diff --git a/codex-rs/core/src/guardian/input_budget.rs b/codex-rs/core/src/guardian/input_budget.rs index 8e4159f2ee..529c0d65a5 100644 --- a/codex-rs/core/src/guardian/input_budget.rs +++ b/codex-rs/core/src/guardian/input_budget.rs @@ -155,6 +155,7 @@ pub(crate) async fn finalize( .collect(), step, session.get_prompt_base_instructions().await, + session.current_window_uses_incremental_tools(step).await, ); let request = session.services.model_client.build_responses_request( &prompt, diff --git a/codex-rs/core/src/guardian/input_budget_tests.rs b/codex-rs/core/src/guardian/input_budget_tests.rs index 6db39e8d79..3f0b66f0c4 100644 --- a/codex-rs/core/src/guardian/input_budget_tests.rs +++ b/codex-rs/core/src/guardian/input_budget_tests.rs @@ -190,7 +190,12 @@ async fn compacted_review_restores_originals_once_and_persists_the_request_prefi session .replace_history(vec![], /*reference_context_item*/ None) .await; - let mut prompt = build_prompt(vec![], &step, session.get_prompt_base_instructions().await); + let mut prompt = build_prompt( + vec![], + &step, + session.get_prompt_base_instructions().await, + /*incremental_tools*/ false, + ); let metadata = session .responses_metadata(&step, CodexResponsesRequestKind::Turn) .await; diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index e9853bf20e..4115e4ade5 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -108,7 +108,13 @@ pub(crate) async fn build_prompt_input_from_session( .await .for_prompt(&step_context.settings.model_info.input_modalities); let base_instructions = sess.get_base_instructions().await; - let prompt = build_prompt(prompt_input, step_context.as_ref(), base_instructions); + let prompt = build_prompt( + prompt_input, + step_context.as_ref(), + base_instructions, + sess.current_window_uses_incremental_tools(&step_context) + .await, + ); Ok(prompt.input) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 279bacf897..960958b290 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3599,16 +3599,18 @@ impl Session { pub(crate) async fn record_step_world_state_if_changed( &self, step_context: &step_context::StepContext, - ) -> CodexResult> { + ) -> CodexResult<()> { let turn_context = step_context.turn.as_ref(); // Render model-visible state from the same step used to build and run tools. - let world_state = Arc::new(self.build_world_state_for_step(step_context).await?); + let world_state = self + .build_world_state_for_step(step_context, /*new_window*/ false) + .await?; let (world_state_snapshot, fragments, world_state_item) = self .state .lock() .await .history - .render_step_world_state(world_state.as_ref()); + .render_step_world_state(&world_state); let items = crate::context_manager::updates::merge_world_state_updates(fragments); if !items.is_empty() { self.record_conversation_items(turn_context, &step_context.settings.model_info, &items) @@ -3625,7 +3627,7 @@ impl Session { self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)]) .await; } - Ok(world_state) + Ok(()) } /// Retains the step captured for execution. @@ -4534,7 +4536,7 @@ impl Session { &self, step_context: &StepContext, world_state: Arc, - ) -> u64 { + ) { let turn_context = step_context.turn.as_ref(); let history = self.clone_history().await; let input_goal_ids = crate::context::UserGoalUpdate::message_ids(history.raw_items()); @@ -4585,7 +4587,6 @@ impl Session { ) .await; self.recompute_token_usage(turn_context).await; - window_number } pub(crate) async fn reference_context_item(&self) -> Option { @@ -4610,7 +4611,7 @@ impl Session { pub(crate) async fn record_context_updates_and_set_reference_context_item( &self, step_context: &StepContext, - ) -> CodexResult> { + ) -> CodexResult<()> { let turn_context = step_context.turn.as_ref(); let reference_context_item = { let state = self.state.lock().await; @@ -4619,11 +4620,13 @@ impl Session { let turn_context_item = step_context.to_turn_context_item(); let turn_context_changed = reference_context_item.as_ref() != Some(&turn_context_item); let should_inject_full_context = reference_context_item.is_none(); - let world_state = Arc::new(self.build_world_state_for_step(step_context).await?); + let world_state = self + .build_world_state_for_step(step_context, should_inject_full_context) + .await?; // Full initial context resets the baseline; later turns persist only its changes. let (mut context_items, world_state_item) = if should_inject_full_context { let (context_updates, snapshot) = self - .build_initial_context_with_world_state(step_context, world_state.as_ref()) + .build_initial_context_with_world_state(step_context, &world_state) .await; let context_items = crate::context_manager::updates::merge_world_state_updates(context_updates); @@ -4639,8 +4642,7 @@ impl Session { } else { let (world_state_items, world_state_item) = { let mut state = self.state.lock().await; - let (fragments, rollout_item) = - state.history.update_world_state(world_state.as_ref()); + let (fragments, rollout_item) = state.history.update_world_state(&world_state); ( crate::context_manager::updates::merge_world_state_updates(fragments), rollout_item, @@ -4657,7 +4659,7 @@ impl Session { // A snapshot can change without producing model-visible or TurnContext updates. let only_world_state_changed = !turn_context_changed && context_items.is_empty(); if only_world_state_changed && world_state_item.is_none() { - return Ok(world_state); + return Ok(()); } if !context_items.is_empty() { self.record_conversation_items( @@ -4674,7 +4676,7 @@ impl Session { } // A snapshot-only change does not require a duplicate TurnContext record. if only_world_state_changed { - return Ok(world_state); + return Ok(()); } // Persist one `TurnContextItem` per real user turn so resume/lazy replay can recover the // latest durable baseline even when this turn emitted no model-visible context diffs. @@ -4685,7 +4687,7 @@ impl Session { // context items. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); - Ok(world_state) + Ok(()) } pub(crate) async fn update_token_usage_info( diff --git a/codex-rs/core/src/session/startup_prewarm.rs b/codex-rs/core/src/session/startup_prewarm.rs index e74e4668af..554e69e178 100644 --- a/codex-rs/core/src/session/startup_prewarm.rs +++ b/codex-rs/core/src/session/startup_prewarm.rs @@ -418,6 +418,9 @@ async fn schedule_startup_prewarm_inner( text: base_instructions, provenance: None, }, + session + .current_window_uses_incremental_tools(&step_context) + .await, ); startup_turn_context.session_telemetry.record_startup_phase( "startup_prewarm_build_prompt", diff --git a/codex-rs/core/src/session/step_activation_tests.rs b/codex-rs/core/src/session/step_activation_tests.rs index 8d5647a4a7..edf95cc3af 100644 --- a/codex-rs/core/src/session/step_activation_tests.rs +++ b/codex-rs/core/src/session/step_activation_tests.rs @@ -675,7 +675,7 @@ async fn submitted_sparse_updates_preserve_captured_steps_and_ordering() { // Even after the live update, an already-captured request retains its own // summary setting in the durable context. for step in [&before, &during, &after] { - let world_state = session + session .record_context_updates_and_set_reference_context_item(step) .await .expect("record captured settings"); @@ -683,6 +683,12 @@ async fn submitted_sparse_updates_preserve_captured_steps_and_ordering() { session.reference_context_item().await.unwrap().summary, step.settings.reasoning_summary, ); + let world_state = Arc::new( + session + .build_world_state_for_step(step, /*new_window*/ true) + .await + .unwrap(), + ); session.start_new_context_window(step, world_state).await; assert_eq!( session.reference_context_item().await.unwrap().summary, diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs index 9bc54aec53..3f4e064650 100644 --- a/codex-rs/core/src/session/step_context.rs +++ b/codex-rs/core/src/session/step_context.rs @@ -49,7 +49,8 @@ pub(crate) struct StepContext { } impl StepContext { - pub(crate) fn uses_incremental_tools(&self) -> bool { + /// Whether new context windows should record tool declarations in history. + pub(crate) fn incremental_tools_enabled(&self) -> bool { self.settings.model_info.use_responses_lite && self .turn diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 598c915886..24e78e645e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4030,7 +4030,7 @@ async fn start_new_context_window_persists_checkpoint_state() { .expect("a fresh cancellation token cannot be cancelled"); let world_state = Arc::new( session - .build_world_state_for_step(&step_context) + .build_world_state_for_step(&step_context, /*new_window*/ true) .await .expect("world state should build"), ); @@ -6762,7 +6762,7 @@ async fn build_initial_context( ) -> Vec { let step_context = StepContext::for_test(Arc::clone(turn_context)); let world_state = session - .build_world_state_for_step(&step_context) + .build_world_state_for_step(&step_context, /*new_window*/ true) .await .expect("world state should build"); let (updates, _) = session @@ -6777,7 +6777,7 @@ pub(crate) async fn build_world_state_from_turn_context( ) -> WorldState { let step_context = StepContext::for_test(Arc::clone(turn_context)); session - .build_world_state_for_step(&step_context) + .build_world_state_for_step(&step_context, /*new_window*/ false) .await .expect("world state should build") } @@ -11375,6 +11375,7 @@ async fn build_initial_context_uses_retained_step_after_model_change() { Vec::new(), |config| { config.features.enable(Feature::TokenBudget).unwrap(); + config.features.enable(Feature::IncrementalTools).unwrap(); }, ) .await; @@ -11392,6 +11393,7 @@ async fn build_initial_context_uses_retained_step_after_model_change() { update_turn_settings_for_test(Arc::get_mut(&mut turn_context).unwrap(), |settings| { let model_info = Arc::make_mut(&mut settings.model_info); model_info.slug = "model-a".to_string(); + model_info.use_responses_lite = true; model_info.context_window = None; model_info.max_context_window = None; let messages = model_info.model_messages.as_mut().unwrap(); @@ -11405,12 +11407,23 @@ async fn build_initial_context_uses_retained_step_after_model_change() { realtime_active: None, })) .await; + session + .replace_history( + vec![user_message("Existing legacy window")], + Some(turn_context.to_turn_context_item()), + ) + .await; let step_a = session .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) .await .unwrap(); - let world_a = Arc::new(session.build_world_state_for_step(&step_a).await.unwrap()); - let (initial_a, _) = crate::compact::build_compaction_replacement_history( + let world_a = Arc::new( + session + .build_world_state_for_step(&step_a, /*new_window*/ true) + .await + .unwrap(), + ); + let (initial_a, expected_snapshot) = crate::compact::build_compaction_replacement_history( &session, &step_a, &world_a, @@ -11440,7 +11453,10 @@ async fn build_initial_context_uses_retained_step_after_model_change() { .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) .await .unwrap(); - let world_b = session.build_world_state_for_step(&step_b).await.unwrap(); + let world_b = session + .build_world_state_for_step(&step_b, /*new_window*/ true) + .await + .unwrap(); let initial_b = session .build_initial_context_with_world_state(&step_b, &world_b) .await @@ -11456,7 +11472,7 @@ async fn build_initial_context_uses_retained_step_after_model_change() { .await; assert_eq!(restored_a, initial_a); - assert_eq!(restored_snapshot, world_a.render_full().0); + assert_eq!(restored_snapshot, expected_snapshot); let initial_a = initial_a .into_iter() .map(|item| item.item) @@ -12050,7 +12066,12 @@ async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: Firs Arc::clone(&primary), Arc::clone(&fallback), &mut client_session, - Arc::new(session.build_world_state_for_step(&fallback).await.unwrap()), + Arc::new( + session + .build_world_state_for_step(&fallback, /*new_window*/ true) + .await + .unwrap(), + ), CompactionReason::ModelDownshift, CompactionPhase::PreTurn, ) diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index f04f326e4d..349376c587 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -1113,7 +1113,7 @@ async fn compaction_initial_context_preserves_separate_guardian_developer_messag let step_context = StepContext::for_test(Arc::clone(&turn_context)); let world_state = Arc::new( session - .build_world_state_for_step(&step_context) + .build_world_state_for_step(&step_context, /*new_window*/ true) .await .expect("world state should build"), ); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 08795333c8..7e49dc3e82 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -11,7 +11,6 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_ use crate::connectors; use crate::context::ContextualUserFragment; use crate::context::UserVerificationNotice; -use crate::context::world_state::WorldState; use crate::cyber_access_program; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::feedback_tags; @@ -279,8 +278,8 @@ pub(crate) async fn run_turn( } Err(err) => return Err(err), }; - // Keep the exact model-visible state used by this turn and its inline compactions. - let (world_state, display_roots) = tokio::join!( + // Record initial context while preparing diff display roots. + let (record_context, display_roots) = tokio::join!( sess.record_context_updates_and_set_reference_context_item(first_step_context.as_ref()), async { // Guardian must not wait for remote Git discovery just to display diff paths. @@ -306,7 +305,7 @@ pub(crate) async fn run_turn( } }, ); - let mut world_state = world_state?; + record_context?; let Some((injection_items, explicitly_enabled_connectors)) = build_skills_and_plugins( &sess, @@ -349,13 +348,11 @@ pub(crate) async fn run_turn( Arc::clone(&first_step_context), Arc::clone(&first_step_context), &mut client_session, - Arc::clone(&world_state), CompactionReason::ContextLimit, CompactionPhase::PreTurn, ) .await?; - world_state = sess - .record_context_updates_and_set_reference_context_item(first_step_context.as_ref()) + sess.record_context_updates_and_set_reference_context_item(first_step_context.as_ref()) .await?; crate::guardian::finalize_guardian_input( &sess, @@ -505,8 +502,7 @@ pub(crate) async fn run_turn( ) .await?; - world_state = sess - .record_step_world_state_if_changed(step_context.as_ref()) + sess.record_step_world_state_if_changed(step_context.as_ref()) .await?; // Isolated Guardian reviewers deliberately have no skill catalog. @@ -627,7 +623,6 @@ pub(crate) async fn run_turn( Arc::clone(&step_context), Arc::clone(&step_context), &mut client_session, - Arc::clone(&world_state), CompactionReason::ContextLimit, CompactionPhase::MidTurn, ) @@ -736,7 +731,6 @@ pub(crate) async fn run_turn( Arc::clone(&step_context), Arc::clone(&step_context), &mut client_session, - Arc::clone(&world_state), CompactionReason::ContextLimit, CompactionPhase::PostTurn, ) @@ -787,7 +781,6 @@ pub(crate) async fn run_turn( Arc::clone(&step_context), Arc::clone(&step_context), &mut client_session, - Arc::clone(&world_state), CompactionReason::ContextLimit, CompactionPhase::MidTurn, ) @@ -1324,13 +1317,11 @@ async fn run_pre_sampling_compact( let step_context = sess .capture_step_context(Arc::clone(turn_context), cancellation_token) .await?; - let world_state = Arc::new(sess.build_world_state_for_step(&step_context).await?); run_auto_compact( sess, Arc::clone(&step_context), step_context, client_session, - world_state, CompactionReason::ContextLimit, CompactionPhase::PreTurn, ) @@ -1422,16 +1413,11 @@ async fn maybe_run_previous_model_inline_compact( let replacement_step_context = sess .capture_speculative_step_context(Arc::clone(turn_context), cancellation_token) .await?; - let world_state = Arc::new( - sess.build_world_state_for_step(&replacement_step_context) - .await?, - ); run_auto_compact( sess, step_context, replacement_step_context, client_session, - world_state, reason, CompactionPhase::PreTurn, ) @@ -1439,7 +1425,7 @@ async fn maybe_run_previous_model_inline_compact( Ok(()) } -/// `world_state` belongs to `replacement_step_context`, which may differ from the compactor. +/// The replacement context may use a different model than the compactor. #[instrument( level = "trace", skip_all, @@ -1450,7 +1436,6 @@ async fn run_auto_compact( step_context: Arc, replacement_step_context: Arc, client_session: &mut ModelClientSession, - world_state: Arc, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { @@ -1461,6 +1446,10 @@ async fn run_auto_compact( { return Ok(()); } + let world_state = Arc::new( + sess.build_world_state_for_step(&replacement_step_context, /*new_window*/ true) + .await?, + ); let _profile_guard = turn_context.turn_timing_state.begin_compaction(); let _compaction_span = trace_span!( "codex.compaction", @@ -1574,17 +1563,18 @@ pub(crate) fn build_prompt( input: Vec, step_context: &StepContext, base_instructions: BaseInstructions, + incremental_tools: bool, ) -> Prompt { let turn_context = &step_context.turn; Prompt { input, - tools: if step_context.uses_incremental_tools() { + tools: if incremental_tools { Arc::default() } else { step_context.tool_router.model_visible_specs() }, parallel_tool_calls: true, - base_instructions: if step_context.uses_incremental_tools() { + base_instructions: if incremental_tools { BaseInstructions { text: String::new(), provenance: None, @@ -1663,6 +1653,8 @@ async fn run_sampling_request( prompt_input, step_context.as_ref(), base_instructions.clone(), + sess.current_window_uses_incremental_tools(&step_context) + .await, ); let responses_metadata = sess .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 1d73601e89..a050fccf13 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -41,11 +41,32 @@ const MAX_ENVIRONMENT_SUBAGENTS: usize = 8; const MAX_ENVIRONMENT_SUBAGENT_BYTES: usize = 1_024; impl Session { + pub(crate) async fn current_window_uses_incremental_tools( + &self, + step_context: &StepContext, + ) -> bool { + if !step_context.settings.model_info.use_responses_lite { + return false; + } + let state = self.state.lock().await; + if state.history.annotated_items().is_empty() { + return step_context.incremental_tools_enabled(); + } + state.history.has_tool_declarations() + } + #[tracing::instrument(name = "world_state.build", level = "info", skip_all)] pub(crate) async fn build_world_state_for_step( &self, step_context: &StepContext, + new_window: bool, ) -> CodexResult { + let incremental_tools = if new_window { + step_context.incremental_tools_enabled() + } else { + self.current_window_uses_incremental_tools(step_context) + .await + }; let turn_context = step_context.turn.as_ref(); let settings = &step_context.settings; let model_info = settings.model_info.as_ref(); @@ -119,7 +140,7 @@ impl Session { String::new() }; let mut world_state = WorldState::default(); - if step_context.uses_incremental_tools() { + if incremental_tools { let specs = step_context.tool_router.model_visible_specs(); let definitions = codex_tools::create_tools_json_for_responses_lite(&specs)?; world_state.add_section(TopLevelToolsState::new(definitions)?); diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 823b064fee..6014a2d7f7 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -45,7 +45,11 @@ impl SessionTask for CompactTask { let step_context = session .capture_step_context(Arc::clone(&ctx), &cancellation_token) .await?; - let world_state = Arc::new(session.build_world_state_for_step(&step_context).await?); + let world_state = Arc::new( + session + .build_world_state_for_step(&step_context, /*new_window*/ true) + .await?, + ); if ctx.config.features.enabled(Feature::TokenBudget) { crate::compact_token_budget::run_manual_compact_task( session, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 79b0f7f50c..6991af1476 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1179,8 +1179,9 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat ) })); + let step_context = StepContext::for_test(Arc::clone(&turn)); let world_state = session - .build_world_state_for_step(&StepContext::for_test(Arc::clone(&turn))) + .build_world_state_for_step(&step_context, /*new_window*/ true) .await .expect("world state should build"); assert_eq!( diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bc57d3c310..bf7eec5963 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -737,6 +737,30 @@ impl TestCodexBuilder { .await } + /// Restarts without changing the executor platform selected by the test process. + pub async fn restart_with_auto_env( + &mut self, + server: &MockServer, + previous: &TestCodex, + ) -> Result { + let rollout_path = previous + .session_configured + .rollout_path + .clone() + .context("rollout path")?; + previous.codex.shutdown_and_wait().await?; + let base_url = format!("{}/v1", server.uri()); + let test_env = test_env().await?; + Box::pin(self.build_with_home_and_base_url( + base_url, + Arc::clone(&previous.home), + Some(rollout_path), + test_env, + /*include_local_environment*/ false, + )) + .await + } + async fn build_with_home_and_base_url( &mut self, base_url: String, diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 28d0554e54..6304ffecdd 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -304,11 +304,13 @@ async fn websocket_test_codex_shell_chain() -> Result<()> { Ok(()) } -#[test_case::test_case(false; "update_plan disabled")] -#[test_case::test_case(true; "update_plan enabled")] +#[test_case::test_case(false, false; "update_plan disabled")] +#[test_case::test_case(true, false; "update_plan enabled")] +#[test_case::test_case(true, true; "incremental tools")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_first_turn_uses_startup_prewarm_and_create( update_plan_enabled: bool, + incremental_tools: bool, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -327,7 +329,18 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create( .with_config(move |config| { config.update_plan_enabled = update_plan_enabled; config.analytics_enabled = Some(false); + if incremental_tools { + config + .features + .enable(Feature::IncrementalTools) + .expect("enable incremental tools"); + } }); + if incremental_tools { + builder = builder.with_model_info_override("gpt-5.2", |model| { + model.use_responses_lite = true; + }); + } let test = builder.build_with_websocket_server(&server).await?; test.submit_turn_with_policy("hello", test.config.legacy_sandbox_policy()) .await?; @@ -341,13 +354,24 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create( .body_json(); let turn = connection.get(1).expect("missing turn request").body_json(); assert_eq!(turn["previous_response_id"], "warm-1"); - assert_eq!( - warmup["input"][0]["content"][0]["text"] - .as_str() - .expect("warmup base instructions") - .contains("update_plan"), - update_plan_enabled - ); + if incremental_tools { + assert_eq!(warmup["input"], json!([])); + assert!( + turn["input"] + .as_array() + .expect("turn input array") + .iter() + .any(|item| item["type"] == "additional_tools") + ); + } else { + assert_eq!( + warmup["input"][0]["content"][0]["text"] + .as_str() + .expect("warmup base instructions") + .contains("update_plan"), + update_plan_enabled + ); + } assert_eq!(warmup["type"].as_str(), Some("response.create")); assert_eq!(warmup["generate"].as_bool(), Some(false)); let warmup_metadata: Value = serde_json::from_str( @@ -361,11 +385,11 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create( warmup_metadata["window_id"].as_str(), warmup["client_metadata"]["x-codex-window-id"].as_str() ); - assert!( + assert_eq!( turn["tools"] .as_array() .is_some_and(|tools| !tools.is_empty()), - "expected request tools to be populated" + !incremental_tools ); assert_eq!(turn["type"].as_str(), Some("response.create")); assert_eq!(turn.get("generate"), None); diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index d29673378f..53369836b0 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -91,6 +91,8 @@ const ONE_PIXEL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ #[path = "scenarios_incremental_tools.rs"] mod incremental_tools; +#[path = "scenarios_incremental_tools_resume.rs"] +mod incremental_tools_resume; #[path = "scenarios_code_mode_settled_helpers_tests.rs"] mod code_mode_settled_helpers; diff --git a/codex-rs/core/tests/suite/scenarios_incremental_tools_resume.rs b/codex-rs/core/tests/suite/scenarios_incremental_tools_resume.rs new file mode 100644 index 0000000000..ccaa42afa1 --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_incremental_tools_resume.rs @@ -0,0 +1,202 @@ +//! Request-history coverage for adopting incremental tools when initial context is rebuilt. + +use anyhow::Result; +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_protocol::openai_models::ToolMode; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodexBuilder; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use serde_json::json; +use test_case::test_case; + +fn builder(incremental_tools: bool, token_budget: bool) -> TestCodexBuilder { + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model_info_override("gpt-5.4", |model| { + model.use_responses_lite = true; + model.tool_mode = Some(ToolMode::CodeMode); + }) + .with_config(move |config| { + if incremental_tools { + config + .features + .enable(Feature::IncrementalTools) + .expect("enable incremental tools"); + } else { + config + .features + .disable(Feature::IncrementalTools) + .expect("disable incremental tools"); + } + if token_budget { + config + .features + .enable(Feature::TokenBudget) + .expect("enable token budget"); + } + config.base_instructions = + Some("Use the available tools to help the user.".to_string()); + config.model_auto_compact_token_limit = Some(100_000); + config.code_mode.disable_in_process_fallback = true; + }) +} + +#[test_case(true; "window_reset")] +#[test_case(false; "remote_compaction")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn legacy_resume_switches_only_after_window_replacement(token_budget: bool) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let mut replies = (1..=2) + .map(|i| { + responses::sse(vec![responses::ev_completed_with_tokens( + &format!("before-{i}"), + if i == 2 && !token_budget { 100_001 } else { 0 }, + )]) + }) + .collect::>(); + if !token_budget { + replies.push(responses::sse(vec![ + json!({"type": "response.output_item.done", "item": {"type": "compaction", "encrypted_content": "COMPACTED_HISTORY"}}), + responses::ev_completed("compact"), + ])); + } + replies.extend( + (1..=4).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) + .build_with_auto_env(&server) + .await?; + initial + .submit_turn("Begin with the legacy tool prefix.") + .await?; + let resumed = builder(/*incremental_tools*/ true, token_budget) + .restart_with_auto_env(&server, &initial) + .await?; + resumed + .submit_turn("Resume without moving the tool prefix.") + .await?; + if token_budget { + resumed.codex.submit(Op::Compact).await?; + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + // A window reset survives restart; remote compaction runs before the next turn. + let migrated = builder(/*incremental_tools*/ true, token_budget) + .restart_with_auto_env(&server, &resumed) + .await?; + migrated + .submit_turn("Continue in the new window with incremental tools.") + .await?; + let migrated = builder(/*incremental_tools*/ true, token_budget) + .restart_with_auto_env(&server, &migrated) + .await?; + migrated + .submit_turn("Resume with the new window unchanged.") + .await?; + let changed = 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, &migrated) + .await?; + changed + .submit_turn("Enable planning and remove shell tools after migration.") + .await?; + changed.submit_turn("Continue with the same tools.").await?; + let requests = mock.requests(); + insta::assert_snapshot!( + if token_budget { + "legacy_resume_window_reset" + } else { + "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.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn disable_incremental_tools_at_next_window() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![responses::ev_completed("initial")]), + responses::sse(vec![responses::ev_completed("disabled")]), + responses::sse(vec![ + json!({"type": "response.output_item.done", "item": {"type": "compaction", "encrypted_content": "COMPACTED_HISTORY"}}), + responses::ev_completed("compact"), + ]), + responses::sse(vec![responses::ev_completed("new-window")]), + responses::sse(vec![responses::ev_completed("resumed")]), + ], + ) + .await; + let session_builder = |enabled| { + builder(enabled, /*token_budget*/ false).with_model_info_override("gpt-5.4", |model| { + model.use_responses_lite = true; + model.tool_mode = Some(ToolMode::Direct); + }) + }; + let initial = session_builder(/*enabled*/ true) + .build_with_auto_env(&server) + .await?; + initial.submit_turn("Begin with all tools.").await?; + let disabled_builder = || { + session_builder(/*enabled*/ false).with_config(|config| { + config + .features + .disable(Feature::ShellTool) + .expect("disable shell"); + }) + }; + let disabled = disabled_builder() + .restart_with_auto_env(&server, &initial) + .await?; + disabled + .submit_turn("Disable incremental tools and remove shell tools in the existing window.") + .await?; + disabled.codex.submit(Op::Compact).await?; + wait_for_event(&disabled.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + disabled.submit_turn("Continue in the new window.").await?; + let resumed = disabled_builder() + .restart_with_auto_env(&server, &disabled) + .await?; + resumed.submit_turn("Resume with the same tools.").await?; + 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(), + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ) + ); + Ok(()) +} 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 new file mode 100644 index 0000000000..d68b5a7977 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__disable_incremental_tools.snap @@ -0,0 +1,73 @@ +--- +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(),)" +--- +Scenario: Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume. + +## 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=44f4cad2ce4ab17d): + - namespace/functions; hash=101d32aa59ea258b + - 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 with all tools. +-- request 2 (turn) -- +05:message/developer: + The following tools are no longer available. Do not call them: + - functions.exec_command + - functions.write_stdin +06:message/user: + +07:message/user: + Disable incremental tools and remove shell tools in the existing window. +-- request 3 (compaction) -- +08: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 + - 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 with all tools. +03:message/developer: + +04:message/user: + +05:message/user: + Disable incremental tools and remove shell tools in the existing window. +06:compaction:encrypted=true; chars=17; hash=6c10f5e81c1f8e16 +07:message/user: + Continue in the new window. +-- request 5 (turn) -- +08:message/user: + +09:message/user: + Resume with the same tools. 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 new file mode 100644 index 0000000000..a7112fc0dc --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_remote_compaction.snap @@ -0,0 +1,90 @@ +--- +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(),)" +--- +Scenario: A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume. + +## 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 with the legacy tool prefix. +-- request 2 (turn) -- +05:message/user: + +06:message/user: + Resume without moving the tool prefix. +-- request 3 (compaction) -- +07:compaction_trigger + +## Window 2 (after request 3: input diverged at item 02) +Settings: same as window 1 +-- request 4 (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/user: + Begin with the legacy tool prefix. +03:message/developer: + +04:message/user: + +05:message/user: + Resume without moving the tool prefix. +06:compaction:encrypted=true; chars=17; hash=6c10f5e81c1f8e16 +07:message/user: + Continue in the new window with incremental tools. +-- request 5 (turn) -- +08:message/user: + +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 + - function/update_plan +11: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: + Enable planning and remove shell tools after migration. +-- request 7 (turn) -- +14:message/user: + Continue with the same 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 new file mode 100644 index 0000000000..9a84c3198f --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools_resume__legacy_resume_window_reset.snap @@ -0,0 +1,102 @@ +--- +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(),)" +--- +Scenario: A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume. + +## 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=30feffe01b108a65): + - namespace/functions; hash=dfb4b8684addc20d + - custom/exec + - function/wait + - function/exec_command + - function/write_stdin + - function/request_user_input + - function/new_context + - function/get_context_remaining + - 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/developer: + + Agent name: /root + First context window id: + Current context window id: + +04:message/user: + +05:message/user: + Begin with the legacy tool prefix. +-- request 2 (turn) -- +06:message/user: + +07:message/user: + Resume without moving the tool prefix. + +## Window 2 (after request 2: input diverged at item 03) +Settings: same as window 1 +-- request 3 (turn) -- +00:additional_tools/developer (2; hash=30feffe01b108a65): + - namespace/functions; hash=dfb4b8684addc20d + - custom/exec + - function/wait + - function/exec_command + - function/write_stdin + - function/request_user_input + - function/new_context + - function/get_context_remaining + - 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/developer: + + Agent name: /root + First context window id: + Current context window id: + Previous context window id: + +04:message/user: + +05:message/user: + +06:message/user: + Continue in the new window with incremental tools. +-- request 4 (turn) -- +07:message/user: + +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 + - function/update_plan +10: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: + Enable planning and remove shell tools after migration. +-- request 6 (turn) -- +13:message/user: + Continue with the same tools. diff --git a/codex-rs/protocol/src/models/item_metadata.rs b/codex-rs/protocol/src/models/item_metadata.rs index eddd29ee58..af08dfa827 100644 --- a/codex-rs/protocol/src/models/item_metadata.rs +++ b/codex-rs/protocol/src/models/item_metadata.rs @@ -6,6 +6,12 @@ use serde::Serialize; #[serde(transparent)] pub struct ContentItemKind(pub String); +impl ContentItemKind { + pub fn as_str(&self) -> &str { + &self.0 + } +} + #[cfg(test)] #[path = "item_metadata_tests.rs"] mod tests;