From 07aefffb1fd344cc89d623a39ed85e276d3afc88 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Thu, 26 Feb 2026 00:12:08 -0800 Subject: [PATCH] core: bundle settings diff updates into one dev/user envelope (#12417) ## Summary - bundle contextual prompt injection into at most one developer message plus one contextual user message in both: - per-turn settings updates - initial context insertion - preserve `` across compaction by rebuilding it through canonical initial-context injection, instead of relying on strip/reattach hacks - centralize contextual user fragment detection in one shared definition table and reuse it for parsing/compaction logic - keep `AGENTS.md` in its natural serialized format: - `# AGENTS.md instructions for {dirname}` - `...` - simplify related tests/helpers and accept the expected snapshot/layout updates from bundled multi-part messages ## Why The goal is to converge toward a simpler, more intentional prompt shape where contextual updates are consistently represented as one developer envelope plus one contextual user envelope, while keeping parsing and compaction behavior aligned with that representation. ## Notable details - the temporary `SettingsUpdateEnvelope` wrapper was removed; these paths now return `Vec` directly - local/remote compaction no longer rely on model-switch strip/restore helpers - contextual user detection is now driven by shared fragment definitions instead of ad hoc matcher assembly - AGENTS/user instructions are still the same logical context; only the synthetic `` wrapper was replaced by the natural AGENTS text format ## Testing - `just fmt` - `cargo test -p codex-app-server codex_message_processor::tests::extract_conversation_summary_prefers_plain_user_messages -- --exact` - `cargo test -p codex-core compact::tests::collect_user_messages_filters_session_prefix_entries --lib -- --exact` - `cargo test -p codex-core --test all 'suite::compact::snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch' -- --exact` - `cargo test -p codex-core --test all 'suite::compact_remote::snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model_switch' -- --exact` - `cargo test -p codex-core --test all 'suite::client::includes_apps_guidance_as_developer_message_when_enabled' -- --exact` - `cargo test -p codex-core --test all 'suite::client::includes_developer_instructions_message_in_request' -- --exact` - `cargo test -p codex-core --test all 'suite::client::includes_user_instructions_message_in_request' -- --exact` - `cargo test -p codex-core --test all 'suite::client::resume_includes_initial_messages_and_sends_prior_items' -- --exact` - `cargo test -p codex-core --test all 'suite::review::review_input_isolated_from_parent_history' -- --exact` - `cargo test -p codex-exec --test all 'suite::resume::exec_resume_last_respects_cwd_filter_and_all_flag' -- --exact` - `cargo test -p core_test_support context_snapshot::tests::full_text_mode_preserves_unredacted_text -- --exact` ## Notes - I also ran several targeted `compact`, `compact_remote`, `prompt_caching`, `model_visible_layout`, and `event_mapping` tests while iterating on prompt-shape changes. - I have not claimed a clean full-workspace `cargo test` from this environment because local sandbox/resource conditions have previously produced unrelated failures in large workspace runs. --- .../app-server/src/codex_message_processor.rs | 2 +- .../app-server/tests/suite/send_message.rs | 22 +- codex-rs/core/src/agent/control.rs | 2 +- codex-rs/core/src/codex.rs | 144 +++++++----- codex-rs/core/src/compact.rs | 218 +++++------------- codex-rs/core/src/compact_remote.rs | 53 +++-- codex-rs/core/src/context_manager/history.rs | 33 +-- .../core/src/context_manager/history_tests.rs | 5 - codex-rs/core/src/context_manager/updates.rs | 105 +++++---- codex-rs/core/src/contextual_user_message.rs | 139 +++++++++++ codex-rs/core/src/environment_context.rs | 20 +- codex-rs/core/src/event_mapping.rs | 37 +-- codex-rs/core/src/instructions/mod.rs | 1 - .../src/instructions/user_instructions.rs | 94 ++------ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/truncation.rs | 2 +- codex-rs/core/src/session_prefix.rs | 42 +--- codex-rs/core/src/tasks/mod.rs | 2 +- codex-rs/core/src/thread_manager.rs | 2 +- codex-rs/core/src/user_shell_command.rs | 37 ++- .../core/tests/common/context_snapshot.rs | 34 ++- codex-rs/core/tests/common/responses.rs | 26 +++ codex-rs/core/tests/suite/client.rs | 159 +++++++------ codex-rs/core/tests/suite/compact.rs | 38 ++- codex-rs/core/tests/suite/prompt_caching.rs | 143 +++++++----- codex-rs/core/tests/suite/review.rs | 8 +- ...t__manual_compact_with_history_shapes.snap | 18 +- ...nual_compact_without_prev_user_shapes.snap | 14 +- ...__compact__mid_turn_compaction_shapes.snap | 23 +- ...mpling_model_switch_compaction_shapes.snap | 35 +-- ...action_context_window_exceeded_shapes.snap | 11 +- ..._compaction_including_incoming_shapes.snap | 26 ++- ...n_strips_incoming_model_switch_shapes.snap | 37 +-- ...te_manual_compact_with_history_shapes.snap | 16 +- ...nual_compact_without_prev_user_shapes.snap | 12 +- ...y_reinjects_above_last_summary_shapes.snap | 8 +- ...te__remote_mid_turn_compaction_shapes.snap | 20 +- ...summary_only_reinjects_context_shapes.snap | 20 +- ...action_context_window_exceeded_shapes.snap | 9 +- ...te_pre_turn_compaction_failure_shapes.snap | 9 +- ..._compaction_including_incoming_shapes.snap | 20 +- ...n_strips_incoming_model_switch_shapes.snap | 31 +-- ...ut_cwd_change_does_not_refresh_agents.snap | 24 +- ...resume_override_matches_rollout_model.snap | 21 +- ...layout_resume_with_personality_change.snap | 26 ++- ...__model_visible_layout_turn_overrides.snap | 27 ++- codex-rs/exec/tests/suite/resume.rs | 3 + 47 files changed, 966 insertions(+), 813 deletions(-) create mode 100644 codex-rs/core/src/contextual_user_message.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 011fdbe72..0d3b98f03 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -7627,7 +7627,7 @@ mod tests { "role": "user", "content": [{ "type": "input_text", - "text": "\n\n".to_string(), + "text": "# AGENTS.md instructions for project\n\n\n\n".to_string(), }], }), json!({ diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index 5dc3c0dbc..e5c023d9d 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -214,17 +214,13 @@ async fn test_send_message_raw_notifications_opt_in() -> Result<()> { }) .await?; - let permissions = read_raw_response_item(&mut mcp, conversation_id).await; - assert_permissions_message(&permissions); - let developer = read_raw_response_item(&mut mcp, conversation_id).await; + assert_permissions_message(&developer); assert_developer_message(&developer, "Use the test harness tools."); - let instructions = read_raw_response_item(&mut mcp, conversation_id).await; - assert_instructions_message(&instructions); - - let environment = read_raw_response_item(&mut mcp, conversation_id).await; - assert_environment_message(&environment); + let contextual_user = read_raw_response_item(&mut mcp, conversation_id).await; + assert_instructions_message(&contextual_user); + assert_environment_message(&contextual_user); let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -545,9 +541,8 @@ fn assert_permissions_message(item: &ResponseItem) { false, ) .into_text(); - assert_eq!( - texts, - vec![expected.as_str()], + assert!( + texts.iter().any(|text| *text == expected), "expected permissions developer message, got {texts:?}" ); } @@ -560,9 +555,8 @@ fn assert_developer_message(item: &ResponseItem, expected_text: &str) { ResponseItem::Message { role, content, .. } => { assert_eq!(role, "developer"); let texts = content_texts(content); - assert_eq!( - texts, - vec![expected_text], + assert!( + texts.contains(&expected_text), "expected developer instructions message, got {texts:?}" ); } diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index e94ac7966..f805a9e74 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -319,8 +319,8 @@ mod tests { use crate::config::Config; use crate::config::ConfigBuilder; use crate::config_loader::LoaderOverrides; + use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG; use crate::features::Feature; - use crate::session_prefix::SUBAGENT_NOTIFICATION_OPEN_TAG; use assert_matches::assert_matches; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b2145afae..68dfdbc1e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1669,7 +1669,10 @@ impl Session { match conversation_history { InitialHistory::New => { // Build and record initial items (user instructions + environment context) - let items = self.build_initial_context(&turn_context).await; + // TODO(ccunningham): Defer initial context insertion until the first real turn + // starts so it reflects the actual first-turn settings (permissions, etc.) and + // we do not emit model-visible "diff" updates before the first user message. + let items = self.build_initial_context(&turn_context, None).await; self.record_conversation_items(&turn_context, &items).await; { let mut state = self.state.lock().await; @@ -1773,7 +1776,7 @@ impl Session { } // Append the current session's initial context after the reconstructed history. - let initial_context = self.build_initial_context(&turn_context).await; + let initial_context = self.build_initial_context(&turn_context, None).await; self.record_conversation_items(&turn_context, &initial_context) .await; { @@ -2862,7 +2865,7 @@ impl Session { } else { let user_messages = collect_user_messages(history.raw_items()); let rebuilt = compact::build_compacted_history( - self.build_initial_context(turn_context).await, + self.build_initial_context(turn_context, None).await, &user_messages, &compacted.message, ); @@ -2990,10 +2993,20 @@ impl Session { pub(crate) async fn build_initial_context( &self, turn_context: &TurnContext, + previous_user_turn_model: Option<&str>, ) -> Vec { - let mut items = Vec::::with_capacity(4); + let mut developer_sections = Vec::::with_capacity(8); + let mut contextual_user_sections = Vec::::with_capacity(2); let shell = self.user_shell(); - items.push( + if let Some(model_switch_message) = + crate::context_manager::updates::build_model_instructions_update_item( + previous_user_turn_model, + turn_context, + ) + { + developer_sections.push(model_switch_message.into_text()); + } + developer_sections.push( DeveloperInstructions::from_policy( turn_context.sandbox_policy.get(), turn_context.approval_policy.value(), @@ -3001,17 +3014,17 @@ impl Session { &turn_context.cwd, turn_context.features.enabled(Feature::RequestPermissions), ) - .into(), + .into_text(), ); if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { - items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); + developer_sections.push(developer_instructions.to_string()); } // Add developer instructions for memories. if let Some(memory_prompt) = build_memory_tool_developer_instructions(&turn_context.config.codex_home).await && turn_context.features.enabled(Feature::MemoryTool) { - items.push(DeveloperInstructions::new(memory_prompt).into()); + developer_sections.push(memory_prompt); } // Add developer instructions from collaboration_mode if they exist and are non-empty let (collaboration_mode, base_instructions) = { @@ -3024,7 +3037,7 @@ impl Session { if let Some(collab_instructions) = DeveloperInstructions::from_collaboration_mode(&collaboration_mode) { - items.push(collab_instructions.into()); + developer_sections.push(collab_instructions.into_text()); } if self.features.enabled(Feature::Personality) && let Some(personality) = turn_context.personality @@ -3039,34 +3052,46 @@ impl Session { personality, ) { - items.push( - DeveloperInstructions::personality_spec_message(personality_message).into(), + developer_sections.push( + DeveloperInstructions::personality_spec_message(personality_message) + .into_text(), ); } } if turn_context.features.enabled(Feature::Apps) { - items.push(DeveloperInstructions::new(render_apps_section()).into()); + developer_sections.push(render_apps_section()); } if turn_context.features.enabled(Feature::CodexGitCommit) && let Some(commit_message_instruction) = commit_message_trailer_instruction( turn_context.config.commit_attribution.as_deref(), ) { - items.push(DeveloperInstructions::new(commit_message_instruction).into()); + developer_sections.push(commit_message_instruction); } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { - items.push( + contextual_user_sections.push( UserInstructions { text: user_instructions.to_string(), directory: turn_context.cwd.to_string_lossy().into_owned(), } - .into(), + .serialize_to_text(), ); } - items.push(ResponseItem::from(EnvironmentContext::from_turn_context( - turn_context, - shell.as_ref(), - ))); + contextual_user_sections.push( + EnvironmentContext::from_turn_context(turn_context, shell.as_ref()).serialize_to_xml(), + ); + + let mut items = Vec::with_capacity(2); + if let Some(developer_message) = + crate::context_manager::updates::build_developer_update_item(developer_sections) + { + items.push(developer_message); + } + if let Some(contextual_user_message) = + crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) + { + items.push(contextual_user_message); + } items } @@ -3111,22 +3136,8 @@ impl Session { let reference_context_item = self.reference_context_item().await; let should_inject_full_context = reference_context_item.is_none(); let context_items = if should_inject_full_context { - let mut initial_context = self.build_initial_context(turn_context).await; - // Full reinjection bypasses the settings-diff path, so add the model-switch - // instruction explicitly when needed. Keep it before the rest of full context so - // model-specific guidance is read first. - if let Some(model_switch_item) = - crate::context_manager::updates::build_model_instructions_update_item( - previous_user_turn_model, - turn_context, - ) - { - // TODO(ccunningham): When a model switch changes the effective personality - // instructions, inject the updated personality spec alongside - // here so resume/model-switch paths can avoid forcing full reinjection. - initial_context.insert(0, model_switch_item); - } - initial_context + self.build_initial_context(turn_context, previous_user_turn_model) + .await } else { // Steady-state path: append only context diffs to minimize token overhead. self.build_settings_update_items( @@ -3516,9 +3527,9 @@ impl Session { use_linux_sandbox_bwrap: turn_context.features.enabled(Feature::UseLinuxSandboxBwrap), }; { - let mut cancel_guard = self.services.mcp_startup_cancellation_token.lock().await; - cancel_guard.cancel(); - *cancel_guard = CancellationToken::new(); + let mut guard = self.services.mcp_startup_cancellation_token.lock().await; + guard.cancel(); + *guard = CancellationToken::new(); } let (refreshed_manager, cancel_token) = McpConnectionManager::new( &mcp_servers, @@ -3532,11 +3543,11 @@ impl Session { ) .await; { - let mut cancel_guard = self.services.mcp_startup_cancellation_token.lock().await; - if cancel_guard.is_cancelled() { + let mut guard = self.services.mcp_startup_cancellation_token.lock().await; + if guard.is_cancelled() { cancel_token.cancel(); } - *cancel_guard = cancel_token; + *guard = cancel_token; } let mut manager = self.services.mcp_connection_manager.write().await; @@ -5011,6 +5022,7 @@ pub(crate) async fn run_turn( &sess, &turn_context, InitialContextInjection::BeforeLastUserMessage, + previous_model.as_deref(), ) .await .is_err() @@ -5134,7 +5146,13 @@ async fn run_pre_sampling_compact( .unwrap_or(i64::MAX); // Compact if the total usage tokens are greater than the auto compact limit if total_usage_tokens >= auto_compact_limit { - run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?; + run_auto_compact( + sess, + turn_context, + InitialContextInjection::DoNotInject, + None, + ) + .await?; } Ok(()) } @@ -5177,6 +5195,7 @@ async fn maybe_run_previous_model_inline_compact( sess, &previous_model_turn_context, InitialContextInjection::DoNotInject, + None, ) .await?; return Ok(true); @@ -5188,12 +5207,14 @@ async fn run_auto_compact( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, + previous_user_turn_model: Option<&str>, ) -> CodexResult<()> { if should_use_remote_compact_task(&turn_context.provider) { run_inline_remote_auto_compact_task( Arc::clone(sess), Arc::clone(turn_context), initial_context_injection, + previous_user_turn_model, ) .await?; } else { @@ -5201,6 +5222,7 @@ async fn run_auto_compact( Arc::clone(sess), Arc::clone(turn_context), initial_context_injection, + previous_user_turn_model, ) .await?; } @@ -7438,7 +7460,7 @@ mod tests { session .record_context_updates_and_set_reference_context_item(&turn_context, None) .await; - expected.extend(session.build_initial_context(&turn_context).await); + expected.extend(session.build_initial_context(&turn_context, None).await); let history_after_seed = session.clone_history().await; assert_eq!(expected, history_after_seed.raw_items()); @@ -7600,7 +7622,7 @@ mod tests { let reconstruction_turn = session.new_default_turn().await; expected.extend( session - .build_initial_context(reconstruction_turn.as_ref()) + .build_initial_context(reconstruction_turn.as_ref(), None) .await, ); let history = session.state.lock().await.clone_history(); @@ -7643,7 +7665,7 @@ mod tests { async fn thread_rollback_drops_last_turn_from_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = sess.build_initial_context(tc.as_ref(), None).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -7714,7 +7736,7 @@ mod tests { async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = sess.build_initial_context(tc.as_ref(), None).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -7742,7 +7764,7 @@ mod tests { async fn thread_rollback_fails_when_turn_in_progress() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = sess.build_initial_context(tc.as_ref(), None).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -7763,7 +7785,7 @@ mod tests { async fn thread_rollback_fails_when_num_turns_is_zero() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = sess.build_initial_context(tc.as_ref(), None).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -8787,7 +8809,7 @@ mod tests { .record_context_updates_and_set_reference_context_item(&turn_context, None) .await; let history = session.clone_history().await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = session.build_initial_context(&turn_context, None).await; assert_eq!(history.raw_items().to_vec(), initial_context); let current_context = session.reference_context_item().await; @@ -8831,10 +8853,28 @@ mod tests { let history = session.clone_history().await; let mut expected_history = vec![compacted_summary]; - expected_history.extend(session.build_initial_context(&turn_context).await); + expected_history.extend(session.build_initial_context(&turn_context, None).await); assert_eq!(history.raw_items().to_vec(), expected_history); } + #[tokio::test] + async fn build_initial_context_prepends_model_switch_message() { + let (session, turn_context) = make_session_and_context().await; + + let initial_context = session + .build_initial_context(&turn_context, Some("previous-regular-model")) + .await; + + let ResponseItem::Message { role, content, .. } = &initial_context[0] else { + panic!("expected developer message"); + }; + assert_eq!(role, "developer"); + let [ContentItem::InputText { text }, ..] = content.as_slice() else { + panic!("expected developer text"); + }; + assert!(text.contains("")); + } + #[tokio::test] async fn run_user_shell_command_does_not_set_reference_context_item() { let (session, _turn_context, rx) = make_session_and_context_with_rx().await; @@ -9156,7 +9196,7 @@ mod tests { let ContentItem::InputText { text } = content_item else { return false; }; - text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG) + text.contains(crate::contextual_user_message::TURN_ABORTED_OPEN_TAG) }) }), "expected a model-visible turn aborted marker in history after interrupt" @@ -9230,7 +9270,7 @@ mod tests { // personality_spec) matches reconstruction. let reconstruction_turn = session.new_default_turn().await; let mut initial_context = session - .build_initial_context(reconstruction_turn.as_ref()) + .build_initial_context(reconstruction_turn.as_ref(), None) .await; // Ensure personality_spec is present when Personality is enabled, so expected matches // what reconstruction produces (build_initial_context may omit it when baked into model). diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 43ee08fd4..7470a1489 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -7,7 +7,6 @@ use crate::client_common::ResponseEvent; use crate::codex::Session; use crate::codex::TurnContext; use crate::codex::get_last_assistant_message_from_turn; -use crate::context_manager::ContextManager; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::protocol::CompactedItem; @@ -51,47 +50,11 @@ pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bo provider.is_openai() } -fn is_model_switch_developer_message(item: &ResponseItem) -> bool { - match item { - ResponseItem::Message { role, content, .. } if role == "developer" => { - matches!( - content.as_slice(), - [ContentItem::InputText { text }] if text.starts_with("\n") - ) - } - _ => false, - } -} - -pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( - history: &mut ContextManager, -) -> Option { - let history_items = history.raw_items(); - let last_user_turn_boundary_index = history_items - .iter() - .rposition(crate::context_manager::is_user_turn_boundary); - let model_switch_index = history_items - .iter() - .enumerate() - .rev() - .find_map(|(i, item)| { - let is_trailing = last_user_turn_boundary_index.is_none_or(|boundary| i > boundary); - if is_trailing && is_model_switch_developer_message(item) { - Some(i) - } else { - None - } - })?; - let mut replacement = history_items.to_vec(); - let model_switch_item = replacement.remove(model_switch_index); - history.replace(replacement); - Some(model_switch_item) -} - pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, initial_context_injection: InitialContextInjection, + previous_user_turn_model: Option<&str>, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { @@ -100,7 +63,14 @@ pub(crate) async fn run_inline_auto_compact_task( text_elements: Vec::new(), }]; - run_compact_task_inner(sess, turn_context, input, initial_context_injection).await?; + run_compact_task_inner( + sess, + turn_context, + input, + initial_context_injection, + previous_user_turn_model, + ) + .await?; Ok(()) } @@ -120,6 +90,7 @@ pub(crate) async fn run_compact_task( turn_context, input, InitialContextInjection::DoNotInject, + None, ) .await } @@ -129,6 +100,7 @@ async fn run_compact_task_inner( turn_context: Arc, input: Vec, initial_context_injection: InitialContextInjection, + previous_user_turn_model: Option<&str>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -136,10 +108,6 @@ async fn run_compact_task_inner( let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let mut history = sess.clone_history().await; - // Keep compaction prompts in-distribution: if a model-switch update was injected at the - // tail of history (between turns), exclude it from the compaction request payload. - let stripped_model_switch_item = - extract_trailing_model_switch_update_for_compaction_request(&mut history); history.record_items( &[initial_input_for_turn.into()], turn_context.truncation_policy, @@ -240,15 +208,12 @@ async fn run_compact_task_inner( initial_context_injection, InitialContextInjection::BeforeLastUserMessage ) { - let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + let initial_context = sess + .build_initial_context(turn_context.as_ref(), previous_user_turn_model) + .await; new_history = insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); } - // Reattach the stripped model-switch update only after successful compaction so the model - // still sees the switch instructions on the next real sampling request. - if let Some(model_switch_item) = stripped_model_switch_item { - new_history.push(model_switch_item); - } let ghost_snapshots: Vec = history_items .iter() .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. })) @@ -491,14 +456,18 @@ mod tests { async fn process_compacted_history_with_test_session( compacted_history: Vec, + previous_user_turn_model: Option<&str>, ) -> (Vec, Vec) { let (session, turn_context) = crate::codex::make_session_and_context().await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = session + .build_initial_context(&turn_context, previous_user_turn_model) + .await; let refreshed = crate::compact_remote::process_compacted_history( &session, &turn_context, compacted_history, InitialContextInjection::BeforeLastUserMessage, + previous_user_turn_model, ) .await; (refreshed, initial_context) @@ -534,107 +503,6 @@ mod tests { assert_eq!(None, joined); } - #[test] - fn extract_trailing_model_switch_update_for_compaction_request_removes_trailing_item() { - let mut history = ContextManager::new(); - history.replace(vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "USER_MESSAGE".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "ASSISTANT_REPLY".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "\nNEW_MODEL_INSTRUCTIONS".to_string(), - }], - end_turn: None, - phase: None, - }, - ]); - - let model_switch_item = - extract_trailing_model_switch_update_for_compaction_request(&mut history); - - assert_eq!(history.raw_items().len(), 2); - assert!(model_switch_item.is_some()); - assert!( - history - .raw_items() - .iter() - .all(|item| !is_model_switch_developer_message(item)) - ); - } - - #[test] - fn extract_trailing_model_switch_update_for_compaction_request_keeps_historical_item() { - let mut history = ContextManager::new(); - history.replace(vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "FIRST_USER_MESSAGE".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "\nOLDER_MODEL_INSTRUCTIONS".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "ASSISTANT_REPLY".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "SECOND_USER_MESSAGE".to_string(), - }], - end_turn: None, - phase: None, - }, - ]); - - let model_switch_item = - extract_trailing_model_switch_update_for_compaction_request(&mut history); - - assert_eq!(history.raw_items().len(), 4); - assert!(model_switch_item.is_none()); - assert!( - history - .raw_items() - .iter() - .any(is_model_switch_developer_message) - ); - } - #[test] fn collect_user_messages_extracts_user_text_only() { let items = vec![ @@ -802,7 +670,7 @@ do things }, ]; let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history).await; + process_compacted_history_with_test_session(compacted_history, None).await; expected.push(ResponseItem::Message { id: None, role: "user".to_string(), @@ -827,7 +695,7 @@ do things phase: None, }]; let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history).await; + process_compacted_history_with_test_session(compacted_history, None).await; expected.push(ResponseItem::Message { id: None, role: "user".to_string(), @@ -903,7 +771,7 @@ keep me updated }, ]; let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history).await; + process_compacted_history_with_test_session(compacted_history, None).await; expected.push(ResponseItem::Message { id: None, role: "user".to_string(), @@ -949,7 +817,7 @@ keep me updated ]; let (refreshed, initial_context) = - process_compacted_history_with_test_session(compacted_history).await; + process_compacted_history_with_test_session(compacted_history, None).await; let mut expected = vec![ ResponseItem::Message { id: None, @@ -983,6 +851,46 @@ keep me updated assert_eq!(refreshed, expected); } + #[tokio::test] + async fn process_compacted_history_reinjects_model_switch_message() { + let compacted_history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let (refreshed, initial_context) = process_compacted_history_with_test_session( + compacted_history, + Some("previous-regular-model"), + ) + .await; + + let ResponseItem::Message { role, content, .. } = &initial_context[0] else { + panic!("expected developer message"); + }; + assert_eq!(role, "developer"); + let [ContentItem::InputText { text }, ..] = content.as_slice() else { + panic!("expected developer text"); + }; + assert!(text.contains("")); + + let mut expected = initial_context; + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }); + assert_eq!(refreshed, expected); + } + #[test] fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() { let compacted_history = vec![ diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 2a9106c72..c019a58ce 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -4,7 +4,6 @@ use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; use crate::compact::InitialContextInjection; -use crate::compact::extract_trailing_model_switch_update_for_compaction_request; use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; @@ -28,8 +27,15 @@ pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, initial_context_injection: InitialContextInjection, + previous_user_turn_model: Option<&str>, ) -> CodexResult<()> { - run_remote_compact_task_inner(&sess, &turn_context, initial_context_injection).await?; + run_remote_compact_task_inner( + &sess, + &turn_context, + initial_context_injection, + previous_user_turn_model, + ) + .await?; Ok(()) } @@ -44,16 +50,28 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; - run_remote_compact_task_inner(&sess, &turn_context, InitialContextInjection::DoNotInject).await + run_remote_compact_task_inner( + &sess, + &turn_context, + InitialContextInjection::DoNotInject, + None, + ) + .await } async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, + previous_user_turn_model: Option<&str>, ) -> CodexResult<()> { - if let Err(err) = - run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await + if let Err(err) = run_remote_compact_task_inner_impl( + sess, + turn_context, + initial_context_injection, + previous_user_turn_model, + ) + .await { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), @@ -68,15 +86,12 @@ async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, + previous_user_turn_model: Option<&str>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(turn_context, &compaction_item) .await; let mut history = sess.clone_history().await; - // Keep compaction prompts in-distribution: if a model-switch update was injected at the - // tail of history (between turns), exclude it from the compaction request payload. - let stripped_model_switch_item = - extract_trailing_model_switch_update_for_compaction_request(&mut history); let base_instructions = sess.get_base_instructions().await; let deleted_items = trim_function_call_history_to_fit_context_window( &mut history, @@ -134,13 +149,9 @@ async fn run_remote_compact_task_inner_impl( turn_context.as_ref(), new_history, initial_context_injection, + previous_user_turn_model, ) .await; - // Reattach the stripped model-switch update only after successful compaction so the model - // still sees the switch instructions on the next real sampling request. - if let Some(model_switch_item) = stripped_model_switch_item { - new_history.push(model_switch_item); - } if !ghost_snapshots.is_empty() { new_history.extend(ghost_snapshots); @@ -170,6 +181,7 @@ pub(crate) async fn process_compacted_history( turn_context: &TurnContext, mut compacted_history: Vec, initial_context_injection: InitialContextInjection, + previous_user_turn_model: Option<&str>, ) -> Vec { // Mid-turn compaction is the only path that must inject initial context above the last user // message in the replacement history. Pre-turn compaction instead injects context after the @@ -178,7 +190,8 @@ pub(crate) async fn process_compacted_history( initial_context_injection, InitialContextInjection::BeforeLastUserMessage ) { - sess.build_initial_context(turn_context).await + sess.build_initial_context(turn_context, previous_user_turn_model) + .await } else { Vec::new() }; @@ -205,10 +218,12 @@ pub(crate) async fn process_compacted_history( fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } if role == "developer" => false, - ResponseItem::Message { role, .. } if role == "user" => matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) - ), + ResponseItem::Message { role, .. } if role == "user" => { + matches!( + crate::event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(_)) + ) + } ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, ResponseItem::Compaction { .. } => true, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index f6f7198f6..016642b33 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,14 +1,11 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; -use crate::instructions::SkillInstructions; -use crate::instructions::UserInstructions; -use crate::session_prefix::is_session_prefix; +use crate::event_mapping::is_contextual_user_message_content; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count_i64; use crate::truncate::truncate_function_output_items_with_policy; use crate::truncate::truncate_text; -use crate::user_shell_command::is_user_shell_command_text; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; @@ -554,33 +551,7 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { return false; }; - if role != "user" { - return false; - } - - if UserInstructions::is_user_instructions(content) - || SkillInstructions::is_skill_instructions(content) - { - return false; - } - - for content_item in content { - match content_item { - ContentItem::InputText { text } => { - if is_session_prefix(text) || is_user_shell_command_text(text) { - return false; - } - } - ContentItem::OutputText { text } => { - if is_session_prefix(text) { - return false; - } - } - ContentItem::InputImage { .. } => {} - } - } - - true + role == "user" && !is_contextual_user_message_content(content) } fn user_message_positions(items: &[ResponseItem]) -> Vec { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 3e11dae40..52fff81ed 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -563,7 +563,6 @@ fn drop_last_n_user_turns_preserves_prefix() { fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { let items = vec![ user_input_text_msg("ctx"), - user_input_text_msg("do the thing"), user_input_text_msg( "# AGENTS.md instructions for test_directory\n\n\ntest_text\n", ), @@ -586,7 +585,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { let expected_prefix_and_first_turn = vec![ user_input_text_msg("ctx"), - user_input_text_msg("do the thing"), user_input_text_msg( "# AGENTS.md instructions for test_directory\n\n\ntest_text\n", ), @@ -608,7 +606,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { let expected_prefix_only = vec![ user_input_text_msg("ctx"), - user_input_text_msg("do the thing"), user_input_text_msg( "# AGENTS.md instructions for test_directory\n\n\ntest_text\n", ), @@ -623,7 +620,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { let mut history = create_history_with_items(vec![ user_input_text_msg("ctx"), - user_input_text_msg("do the thing"), user_input_text_msg( "# AGENTS.md instructions for test_directory\n\n\ntest_text\n", ), @@ -644,7 +640,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { let mut history = create_history_with_items(vec![ user_input_text_msg("ctx"), - user_input_text_msg("do the thing"), user_input_text_msg( "# AGENTS.md instructions for test_directory\n\n\ntest_text\n", ), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 54c5667d4..b9fc537ec 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -4,6 +4,7 @@ use crate::features::Feature; use crate::shell::Shell; use codex_execpolicy::Policy; use codex_protocol::config_types::Personality; +use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; @@ -30,7 +31,7 @@ fn build_permissions_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, exec_policy: &Policy, -) -> Option { +) -> Option { let prev = previous?; if prev.sandbox_policy == *next.sandbox_policy.get() && prev.approval_policy == next.approval_policy.value() @@ -38,27 +39,26 @@ fn build_permissions_update_item( return None; } - Some( - DeveloperInstructions::from_policy( - next.sandbox_policy.get(), - next.approval_policy.value(), - exec_policy, - &next.cwd, - next.features.enabled(Feature::RequestPermissions), - ) - .into(), - ) + Some(DeveloperInstructions::from_policy( + next.sandbox_policy.get(), + next.approval_policy.value(), + exec_policy, + &next.cwd, + next.features.enabled(Feature::RequestPermissions), + )) } fn build_collaboration_mode_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, -) -> Option { +) -> Option { let prev = previous?; if prev.collaboration_mode.as_ref() != Some(&next.collaboration_mode) { // If the next mode has empty developer instructions, this returns None and we emit no // update, so prior collaboration instructions remain in the prompt history. - Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into()) + Some(DeveloperInstructions::from_collaboration_mode( + &next.collaboration_mode, + )?) } else { None } @@ -68,7 +68,7 @@ fn build_personality_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, personality_feature_enabled: bool, -) -> Option { +) -> Option { if !personality_feature_enabled { return None; } @@ -82,8 +82,7 @@ fn build_personality_update_item( { let model_info = &next.model_info; let personality_message = personality_message_for(model_info, personality); - personality_message - .map(|message| DeveloperInstructions::personality_spec_message(message).into()) + personality_message.map(DeveloperInstructions::personality_spec_message) } else { None } @@ -103,7 +102,7 @@ pub(crate) fn personality_message_for( pub(crate) fn build_model_instructions_update_item( previous_user_turn_model: Option<&str>, next: &TurnContext, -) -> Option { +) -> Option { let previous_model = previous_user_turn_model?; if previous_model == next.model_info.slug { return None; @@ -114,7 +113,36 @@ pub(crate) fn build_model_instructions_update_item( return None; } - Some(DeveloperInstructions::model_switch_message(model_instructions).into()) + Some(DeveloperInstructions::model_switch_message( + model_instructions, + )) +} + +pub(crate) fn build_developer_update_item(text_sections: Vec) -> Option { + build_text_message("developer", text_sections) +} + +pub(crate) fn build_contextual_user_message(text_sections: Vec) -> Option { + build_text_message("user", text_sections) +} + +fn build_text_message(role: &str, text_sections: Vec) -> Option { + if text_sections.is_empty() { + return None; + } + + let content = text_sections + .into_iter() + .map(|text| ContentItem::InputText { text }) + .collect(); + + Some(ResponseItem::Message { + id: None, + role: role.to_string(), + content, + end_turn: None, + phase: None, + }) } pub(crate) fn build_settings_update_items( @@ -125,29 +153,26 @@ pub(crate) fn build_settings_update_items( exec_policy: &Policy, personality_feature_enabled: bool, ) -> Vec { - let mut update_items = Vec::new(); + let contextual_user_message = build_environment_update_item(previous, next, shell); + let developer_update_sections = [ + // Keep model-switch instructions first so model-specific guidance is read before + // any other context diffs on this turn. + build_model_instructions_update_item(previous_user_turn_model, next), + build_permissions_update_item(previous, next, exec_policy), + build_collaboration_mode_update_item(previous, next), + build_personality_update_item(previous, next, personality_feature_enabled), + ] + .into_iter() + .flatten() + .map(DeveloperInstructions::into_text) + .collect(); - // Keep model-switch instructions first so model-specific guidance is read before - // any other context diffs on this turn. - if let Some(model_instructions_item) = - build_model_instructions_update_item(previous_user_turn_model, next) - { - update_items.push(model_instructions_item); + let mut items = Vec::with_capacity(2); + if let Some(developer_message) = build_developer_update_item(developer_update_sections) { + items.push(developer_message); } - if let Some(env_item) = build_environment_update_item(previous, next, shell) { - update_items.push(env_item); + if let Some(contextual_user_message) = contextual_user_message { + items.push(contextual_user_message); } - if let Some(permissions_item) = build_permissions_update_item(previous, next, exec_policy) { - update_items.push(permissions_item); - } - if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) { - update_items.push(collaboration_mode_item); - } - if let Some(personality_item) = - build_personality_update_item(previous, next, personality_feature_enabled) - { - update_items.push(personality_item); - } - - update_items + items } diff --git a/codex-rs/core/src/contextual_user_message.rs b/codex-rs/core/src/contextual_user_message.rs new file mode 100644 index 000000000..51a2d23ea --- /dev/null +++ b/codex-rs/core/src/contextual_user_message.rs @@ -0,0 +1,139 @@ +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; +use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; + +pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for "; +pub(crate) const AGENTS_MD_END_MARKER: &str = ""; +pub(crate) const SKILL_OPEN_TAG: &str = ""; +pub(crate) const SKILL_CLOSE_TAG: &str = ""; +pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = ""; +pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = ""; +pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; +pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = ""; +pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = ""; +pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = ""; + +#[derive(Clone, Copy)] +pub(crate) struct ContextualUserFragmentDefinition { + start_marker: &'static str, + end_marker: &'static str, +} + +impl ContextualUserFragmentDefinition { + pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self { + Self { + start_marker, + end_marker, + } + } + + pub(crate) fn matches_text(&self, text: &str) -> bool { + let trimmed = text.trim_start(); + let starts_with_marker = trimmed + .get(..self.start_marker.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker)); + let trimmed = trimmed.trim_end(); + let ends_with_marker = trimmed + .get(trimmed.len().saturating_sub(self.end_marker.len())..) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker)); + starts_with_marker && ends_with_marker + } + + pub(crate) const fn start_marker(&self) -> &'static str { + self.start_marker + } + + pub(crate) const fn end_marker(&self) -> &'static str { + self.end_marker + } + + pub(crate) fn wrap(&self, body: String) -> String { + format!("{}\n{}\n{}", self.start_marker, body, self.end_marker) + } + + pub(crate) fn into_message(self, text: String) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text }], + end_turn: None, + phase: None, + } + } +} + +pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER); +pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new( + ENVIRONMENT_CONTEXT_OPEN_TAG, + ENVIRONMENT_CONTEXT_CLOSE_TAG, + ); +pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG); +pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new( + USER_SHELL_COMMAND_OPEN_TAG, + USER_SHELL_COMMAND_CLOSE_TAG, + ); +pub(crate) const TURN_ABORTED_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new(TURN_ABORTED_OPEN_TAG, TURN_ABORTED_CLOSE_TAG); +pub(crate) const SUBAGENT_NOTIFICATION_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new( + SUBAGENT_NOTIFICATION_OPEN_TAG, + SUBAGENT_NOTIFICATION_CLOSE_TAG, + ); + +const CONTEXTUAL_USER_FRAGMENTS: &[ContextualUserFragmentDefinition] = &[ + AGENTS_MD_FRAGMENT, + ENVIRONMENT_CONTEXT_FRAGMENT, + SKILL_FRAGMENT, + USER_SHELL_COMMAND_FRAGMENT, + TURN_ABORTED_FRAGMENT, + SUBAGENT_NOTIFICATION_FRAGMENT, +]; + +pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { + let ContentItem::InputText { text } = content_item else { + return false; + }; + CONTEXTUAL_USER_FRAGMENTS + .iter() + .any(|definition| definition.matches_text(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_environment_context_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "\n/tmp\n".to_string(), + })); + } + + #[test] + fn detects_agents_instructions_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "# AGENTS.md instructions for /tmp\n\n\nbody\n" + .to_string(), + })); + } + + #[test] + fn detects_subagent_notification_fragment_case_insensitively() { + assert!( + SUBAGENT_NOTIFICATION_FRAGMENT + .matches_text("{}") + ); + } + + #[test] + fn ignores_regular_user_text() { + assert!(!is_contextual_user_fragment(&ContentItem::InputText { + text: "hello".to_string(), + })); + } +} diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 3c6df40ad..8d8d3c6de 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -1,9 +1,7 @@ use crate::codex::TurnContext; +use crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT; use crate::shell::Shell; -use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; -use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use serde::Deserialize; @@ -40,7 +38,6 @@ impl EnvironmentContext { let EnvironmentContext { cwd, network, - // should compare all fields except shell shell: _, } = other; self.cwd == *cwd && self.network == *network @@ -122,7 +119,7 @@ impl EnvironmentContext { /// /// ``` pub fn serialize_to_xml(self) -> String { - let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()]; + let mut lines = Vec::new(); if let Some(cwd) = self.cwd { lines.push(format!(" {}", cwd.to_string_lossy())); } @@ -145,22 +142,13 @@ impl EnvironmentContext { // lines.push(" ".to_string()); } } - lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string()); - lines.join("\n") + ENVIRONMENT_CONTEXT_FRAGMENT.wrap(lines.join("\n")) } } impl From for ResponseItem { fn from(ec: EnvironmentContext) -> Self { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: ec.serialize_to_xml(), - }], - end_turn: None, - phase: None, - } + ENVIRONMENT_CONTEXT_FRAGMENT.into_message(ec.serialize_to_xml()) } } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 16f7e1c47..afffdb0f1 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -18,16 +18,15 @@ use codex_protocol::user_input::UserInput; use tracing::warn; use uuid::Uuid; -use crate::instructions::SkillInstructions; -use crate::instructions::UserInstructions; -use crate::session_prefix::is_session_prefix; -use crate::user_shell_command::is_user_shell_command_text; +use crate::contextual_user_message::is_contextual_user_fragment; use crate::web_search::web_search_action_detail; +pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { + message.iter().any(is_contextual_user_fragment) +} + fn parse_user_message(message: &[ContentItem]) -> Option { - if UserInstructions::is_user_instructions(message) - || SkillInstructions::is_skill_instructions(message) - { + if is_contextual_user_message_content(message) { return None; } @@ -44,9 +43,6 @@ fn parse_user_message(message: &[ContentItem]) -> Option { { continue; } - if is_session_prefix(text) || is_user_shell_command_text(text) { - return None; - } content.push(UserInput::Text { text: text.clone(), // Model input content does not carry UI element ranges. @@ -59,9 +55,6 @@ fn parse_user_message(message: &[ContentItem]) -> Option { }); } ContentItem::OutputText { text } => { - if is_session_prefix(text) { - return None; - } warn!("Output text in user message: {}", text); } } @@ -299,7 +292,7 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "test_text".to_string(), + text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], end_turn: None, phase: None, @@ -341,6 +334,22 @@ mod tests { end_turn: None, phase: None, }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "ctx".to_string(), + }, + ContentItem::InputText { + text: + "# AGENTS.md instructions for dir\n\n\nbody\n" + .to_string(), + }, + ], + end_turn: None, + phase: None, + }, ]; for item in items { diff --git a/codex-rs/core/src/instructions/mod.rs b/codex-rs/core/src/instructions/mod.rs index 9da925ac7..9f1d95d2f 100644 --- a/codex-rs/core/src/instructions/mod.rs +++ b/codex-rs/core/src/instructions/mod.rs @@ -1,6 +1,5 @@ mod user_instructions; pub(crate) use user_instructions::SkillInstructions; -pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY; pub use user_instructions::USER_INSTRUCTIONS_PREFIX; pub(crate) use user_instructions::UserInstructions; diff --git a/codex-rs/core/src/instructions/user_instructions.rs b/codex-rs/core/src/instructions/user_instructions.rs index 525834847..09e6e4c2f 100644 --- a/codex-rs/core/src/instructions/user_instructions.rs +++ b/codex-rs/core/src/instructions/user_instructions.rs @@ -1,12 +1,12 @@ use serde::Deserialize; use serde::Serialize; -use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; -pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = ""; +use crate::contextual_user_message::AGENTS_MD_FRAGMENT; +use crate::contextual_user_message::SKILL_FRAGMENT; + pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for "; -pub const SKILL_INSTRUCTIONS_PREFIX: &str = " bool { - if let [ContentItem::InputText { text }] = message { - text.starts_with(USER_INSTRUCTIONS_PREFIX) - || text.starts_with(USER_INSTRUCTIONS_OPEN_TAG_LEGACY) - } else { - false - } + pub(crate) fn serialize_to_text(&self) -> String { + format!( + "{prefix}{directory}\n\n\n{contents}\n{suffix}", + prefix = AGENTS_MD_FRAGMENT.start_marker(), + directory = self.directory, + contents = self.text, + suffix = AGENTS_MD_FRAGMENT.end_marker(), + ) } } impl From for ResponseItem { fn from(ui: UserInstructions) -> Self { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!( - "{USER_INSTRUCTIONS_PREFIX}{directory}\n\n\n{contents}\n", - directory = ui.directory, - contents = ui.text - ), - }], - end_turn: None, - phase: None, - } + AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text()) } } @@ -52,36 +41,21 @@ pub(crate) struct SkillInstructions { pub contents: String, } -impl SkillInstructions { - pub fn is_skill_instructions(message: &[ContentItem]) -> bool { - if let [ContentItem::InputText { text }] = message { - text.starts_with(SKILL_INSTRUCTIONS_PREFIX) - } else { - false - } - } -} +impl SkillInstructions {} impl From for ResponseItem { fn from(si: SkillInstructions) -> Self { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!( - "\n{}\n{}\n{}\n", - si.name, si.path, si.contents - ), - }], - end_turn: None, - phase: None, - } + SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!( + "{}\n{}\n{}", + si.name, si.path, si.contents + ))) } } #[cfg(test)] mod tests { use super::*; + use codex_protocol::models::ContentItem; use pretty_assertions::assert_eq; #[test] @@ -110,21 +84,10 @@ mod tests { #[test] fn test_is_user_instructions() { - assert!(UserInstructions::is_user_instructions( - &[ContentItem::InputText { - text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), - }] + assert!(AGENTS_MD_FRAGMENT.matches_text( + "# AGENTS.md instructions for test_directory\n\n\ntest_text\n" )); - assert!(UserInstructions::is_user_instructions(&[ - ContentItem::InputText { - text: "test_text".to_string(), - } - ])); - assert!(!UserInstructions::is_user_instructions(&[ - ContentItem::InputText { - text: "test_text".to_string(), - } - ])); + assert!(!AGENTS_MD_FRAGMENT.matches_text("test_text")); } #[test] @@ -154,16 +117,9 @@ mod tests { #[test] fn test_is_skill_instructions() { - assert!(SkillInstructions::is_skill_instructions(&[ - ContentItem::InputText { - text: "\ndemo-skill\nskills/demo/SKILL.md\nbody\n" - .to_string(), - } - ])); - assert!(!SkillInstructions::is_skill_instructions(&[ - ContentItem::InputText { - text: "regular text".to_string(), - } - ])); + assert!(SKILL_FRAGMENT.matches_text( + "\ndemo-skill\nskills/demo/SKILL.md\nbody\n" + )); + assert!(!SKILL_FRAGMENT.matches_text("regular text")); } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index c34f33f39..7edb94b79 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -27,6 +27,7 @@ pub mod config; pub mod config_loader; pub mod connectors; mod context_manager; +mod contextual_user_message; pub mod custom_prompts; pub mod env; mod environment_context; diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index c50eacc48..7b316c0bb 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -193,7 +193,7 @@ mod tests { #[tokio::test] async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context).await; + let mut items = session.build_initial_context(&turn_context, None).await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/src/session_prefix.rs b/codex-rs/core/src/session_prefix.rs index 5f8516ba9..ebf068894 100644 --- a/codex-rs/core/src/session_prefix.rs +++ b/codex-rs/core/src/session_prefix.rs @@ -1,28 +1,8 @@ use codex_protocol::protocol::AgentStatus; -/// Helpers for identifying model-visible "session prefix" messages. -/// -/// A session prefix is a user-role message that carries configuration or state needed by -/// follow-up turns (e.g. ``, ``). These items are persisted in -/// history so the model can see them, but they are not user intent and must not create user-turn -/// boundaries. -pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; -pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; -pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = ""; -pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = ""; - -fn starts_with_ascii_case_insensitive(text: &str, prefix: &str) -> bool { - text.get(..prefix.len()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)) -} - -/// Returns true if `text` starts with a session prefix marker (case-insensitive). -pub(crate) fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - starts_with_ascii_case_insensitive(trimmed, ENVIRONMENT_CONTEXT_OPEN_TAG) - || starts_with_ascii_case_insensitive(trimmed, TURN_ABORTED_OPEN_TAG) - || starts_with_ascii_case_insensitive(trimmed, SUBAGENT_NOTIFICATION_OPEN_TAG) -} +/// Helpers for model-visible session state markers that are stored in user-role +/// messages but are not user intent. +use crate::contextual_user_message::SUBAGENT_NOTIFICATION_FRAGMENT; pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &AgentStatus) -> String { let payload_json = serde_json::json!({ @@ -30,19 +10,5 @@ pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &Agen "status": status, }) .to_string(); - format!("{SUBAGENT_NOTIFICATION_OPEN_TAG}\n{payload_json}\n{SUBAGENT_NOTIFICATION_CLOSE_TAG}") -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn is_session_prefix_is_case_insensitive() { - assert_eq!( - is_session_prefix("{}"), - true - ); - } + SUBAGENT_NOTIFICATION_FRAGMENT.wrap(payload_json) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 861b18a52..97719f104 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -21,12 +21,12 @@ use tracing::warn; use crate::AuthManager; use crate::codex::Session; use crate::codex::TurnContext; +use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG; use crate::models_manager::manager::ModelsManager; use crate::protocol::EventMsg; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; -use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 41280456d..f653b3dfc 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -690,7 +690,7 @@ mod tests { #[tokio::test] async fn ignores_session_prefix_messages_when_truncating() { let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context).await; + let mut items = session.build_initial_context(&turn_context, None).await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/src/user_shell_command.rs b/codex-rs/core/src/user_shell_command.rs index 80128df00..e7921c69f 100644 --- a/codex-rs/core/src/user_shell_command.rs +++ b/codex-rs/core/src/user_shell_command.rs @@ -1,21 +1,12 @@ use std::time::Duration; -use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use crate::codex::TurnContext; +use crate::contextual_user_message::USER_SHELL_COMMAND_FRAGMENT; use crate::exec::ExecToolCallOutput; use crate::tools::format_exec_output_str; -pub const USER_SHELL_COMMAND_OPEN: &str = ""; -pub const USER_SHELL_COMMAND_CLOSE: &str = ""; - -pub fn is_user_shell_command_text(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with(USER_SHELL_COMMAND_OPEN) -} - fn format_duration_line(duration: Duration) -> String { let duration_seconds = duration.as_secs_f64(); format!("Duration: {duration_seconds:.4} seconds") @@ -48,7 +39,7 @@ pub fn format_user_shell_command_record( turn_context: &TurnContext, ) -> String { let body = format_user_shell_command_body(command, exec_output, turn_context); - format!("{USER_SHELL_COMMAND_OPEN}\n{body}\n{USER_SHELL_COMMAND_CLOSE}") + USER_SHELL_COMMAND_FRAGMENT.wrap(body) } pub fn user_shell_command_record_item( @@ -56,15 +47,11 @@ pub fn user_shell_command_record_item( exec_output: &ExecToolCallOutput, turn_context: &TurnContext, ) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format_user_shell_command_record(command, exec_output, turn_context), - }], - end_turn: None, - phase: None, - } + USER_SHELL_COMMAND_FRAGMENT.into_message(format_user_shell_command_record( + command, + exec_output, + turn_context, + )) } #[cfg(test)] @@ -72,14 +59,16 @@ mod tests { use super::*; use crate::codex::make_session_and_context; use crate::exec::StreamOutput; + use codex_protocol::models::ContentItem; use pretty_assertions::assert_eq; #[test] fn detects_user_shell_command_text_variants() { - assert!(is_user_shell_command_text( - "\necho hi\n" - )); - assert!(!is_user_shell_command_text("echo hi")); + assert!( + USER_SHELL_COMMAND_FRAGMENT + .matches_text("\necho hi\n") + ); + assert!(!USER_SHELL_COMMAND_FRAGMENT.matches_text("echo hi")); } #[tokio::test] diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index f9362ee30..24442dd4b 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -62,7 +62,7 @@ pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshot match item_type { "message" => { let role = item.get("role").and_then(Value::as_str).unwrap_or("unknown"); - let text = item + let rendered_parts = item .get("content") .and_then(Value::as_array) .map(|content| { @@ -93,11 +93,27 @@ pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshot } }) .collect::>() - .join(" | ") }) - .filter(|text| !text.is_empty()) - .unwrap_or_else(|| "".to_string()); - format!("{idx:02}:message/{role}:{text}") + .unwrap_or_default(); + let role = if rendered_parts.len() > 1 { + format!("{role}[{}]", rendered_parts.len()) + } else { + role.to_string() + }; + if rendered_parts.is_empty() { + return format!("{idx:02}:message/{role}:"); + } + if rendered_parts.len() == 1 { + return format!("{idx:02}:message/{role}:{}", rendered_parts[0]); + } + + let parts = rendered_parts + .iter() + .enumerate() + .map(|(part_idx, part)| format!(" [{:02}] {part}", part_idx + 1)) + .collect::>() + .join("\n"); + format!("{idx:02}:message/{role}:\n{parts}") } "function_call" => { let name = item.get("name").and_then(Value::as_str).unwrap_or("unknown"); @@ -258,7 +274,7 @@ mod tests { "role": "user", "content": [{ "type": "input_text", - "text": "# AGENTS.md instructions for /tmp/example" + "text": "# AGENTS.md instructions for /tmp/example\n\n\nbody\n" }] })]; @@ -269,7 +285,7 @@ mod tests { assert_eq!( rendered, - "00:message/user:# AGENTS.md instructions for /tmp/example" + r"00:message/user:# AGENTS.md instructions for /tmp/example\n\n\nbody\n" ); } @@ -280,7 +296,7 @@ mod tests { "role": "user", "content": [{ "type": "input_text", - "text": "# AGENTS.md instructions for /tmp/example" + "text": "# AGENTS.md instructions for /tmp/example\n\n\nbody\n" }] })]; @@ -333,7 +349,7 @@ mod tests { assert_eq!( rendered, - "00:message/user: | | " + "00:message/user[3]:\n [01] \n [02] \n [03] " ); } } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 4c7f26ddb..ca5075572 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -141,6 +141,32 @@ impl ResponsesRequest { .collect() } + /// Returns `input_text` spans grouped by `message` input for the provided role. + pub fn message_input_text_groups(&self, role: &str) -> Vec> { + self.inputs_of_type("message") + .into_iter() + .filter(|item| item.get("role").and_then(Value::as_str) == Some(role)) + .filter_map(|item| item.get("content").and_then(Value::as_array).cloned()) + .map(|content| { + content + .into_iter() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text")) + .filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned)) + .collect() + }) + .collect() + } + + pub fn has_message_with_input_texts( + &self, + role: &str, + predicate: impl Fn(&[String]) -> bool, + ) -> bool { + self.message_input_text_groups(role) + .iter() + .any(|texts| predicate(texts)) + } + /// Returns all `input_image` `image_url` spans from `message` inputs for the provided role. pub fn message_input_image_urls(&self, role: &str) -> Vec { self.inputs_of_type("message") diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e040e61e7..4f67ea6fa 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -74,40 +74,14 @@ fn assert_message_role(request_body: &serde_json::Value, role: &str) { assert_eq!(request_body["role"].as_str().unwrap(), role); } -#[expect(clippy::expect_used)] -fn assert_message_equals(request_body: &serde_json::Value, text: &str) { - let content = request_body["content"][0]["text"] - .as_str() - .expect("invalid message content"); - - assert_eq!( - content, text, - "expected message content '{content}' to equal '{text}'" - ); -} - -#[expect(clippy::expect_used)] -fn assert_message_starts_with(request_body: &serde_json::Value, text: &str) { - let content = request_body["content"][0]["text"] - .as_str() - .expect("invalid message content"); - - assert!( - content.starts_with(text), - "expected message content '{content}' to start with '{text}'" - ); -} - -#[expect(clippy::expect_used)] -fn assert_message_ends_with(request_body: &serde_json::Value, text: &str) { - let content = request_body["content"][0]["text"] - .as_str() - .expect("invalid message content"); - - assert!( - content.ends_with(text), - "expected message content '{content}' to end with '{text}'" - ); +#[expect(clippy::unwrap_used)] +fn message_input_texts(item: &serde_json::Value) -> Vec<&str> { + item["content"] + .as_array() + .unwrap() + .iter() + .filter_map(|entry| entry.get("text").and_then(|text| text.as_str())) + .collect() } /// Writes an `auth.json` into the provided `codex_home` with the specified parameters. @@ -305,19 +279,15 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { let request = resp_mock.single_request(); let request_body = request.body_json(); let input = request_body["input"].as_array().expect("input array"); - let messages: Vec<(String, String)> = input - .iter() - .filter_map(|item| { - let role = item.get("role")?.as_str()?; - let text = item - .get("content")? - .as_array()? - .first()? - .get("text")? - .as_str()?; - Some((role.to_string(), text.to_string())) - }) - .collect(); + let mut messages: Vec<(String, String)> = Vec::new(); + for item in input { + let Some(role) = item.get("role").and_then(|role| role.as_str()) else { + continue; + }; + for text in message_input_texts(item) { + messages.push((role.to_string(), text.to_string())); + } + } let pos_prior_user = messages .iter() .position(|(role, text)| role == "user" && text == "resumed user message") @@ -354,8 +324,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .position(|(role, text)| { role == "user" && text.contains("be nice") - && (text.starts_with("# AGENTS.md instructions for ") - || text.starts_with("")) + && (text.starts_with("# AGENTS.md instructions for ")) }) .expect("user instructions"); let pos_environment = messages @@ -664,16 +633,27 @@ async fn includes_user_instructions_message_in_request() { ); assert_message_role(&request_body["input"][1], "user"); - assert_message_starts_with(&request_body["input"][1], "# AGENTS.md instructions for "); - assert_message_ends_with(&request_body["input"][1], ""); - let ui_text = request_body["input"][1]["content"][0]["text"] - .as_str() + let user_context_texts = message_input_texts(&request_body["input"][1]); + assert!( + user_context_texts + .iter() + .any(|text| text.starts_with("# AGENTS.md instructions for ")), + "expected AGENTS text in contextual user message, got {user_context_texts:?}" + ); + let ui_text = user_context_texts + .iter() + .copied() + .find(|text| text.contains("")) .expect("invalid message content"); assert!(ui_text.contains("")); assert!(ui_text.contains("be nice")); - assert_message_role(&request_body["input"][2], "user"); - assert_message_starts_with(&request_body["input"][2], ""); - assert_message_ends_with(&request_body["input"][2], ""); + assert!( + user_context_texts + .iter() + .any(|text| text.starts_with("") + && text.ends_with("")), + "expected environment context in contextual user message, got {user_context_texts:?}" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -727,10 +707,14 @@ async fn includes_apps_guidance_as_developer_message_when_enabled() { && item .get("content") .and_then(|value| value.as_array()) - .and_then(|value| value.first()) - .and_then(|value| value.get("text")) - .and_then(|value| value.as_str()) - .is_some_and(|text| text.contains(apps_snippet)) + .is_some_and(|content| { + content.iter().any(|entry| { + entry + .get("text") + .and_then(|value| value.as_str()) + .is_some_and(|text| text.contains(apps_snippet)) + }) + }) }); assert!( has_developer_apps_guidance, @@ -742,10 +726,14 @@ async fn includes_apps_guidance_as_developer_message_when_enabled() { && item .get("content") .and_then(|value| value.as_array()) - .and_then(|value| value.first()) - .and_then(|value| value.get("text")) - .and_then(|value| value.as_str()) - .is_some_and(|text| text.contains(apps_snippet)) + .is_some_and(|content| { + content.iter().any(|entry| { + entry + .get("text") + .and_then(|value| value.as_str()) + .is_some_and(|text| text.contains(apps_snippet)) + }) + }) }); assert!( !has_user_apps_guidance, @@ -1283,19 +1271,42 @@ async fn includes_developer_instructions_message_in_request() { "expected permissions message to mention sandbox_mode, got {permissions_text:?}" ); - assert_message_role(&request_body["input"][1], "developer"); - assert_message_equals(&request_body["input"][1], "be useful"); - assert_message_role(&request_body["input"][2], "user"); - assert_message_starts_with(&request_body["input"][2], "# AGENTS.md instructions for "); - assert_message_ends_with(&request_body["input"][2], ""); - let ui_text = request_body["input"][2]["content"][0]["text"] - .as_str() + let developer_messages: Vec<&serde_json::Value> = request_body["input"] + .as_array() + .expect("input array") + .iter() + .filter(|item| item.get("role").and_then(|role| role.as_str()) == Some("developer")) + .collect(); + assert!( + developer_messages + .iter() + .any(|item| message_input_texts(item).contains(&"be useful")), + "expected developer instructions in a developer message, got {:?}", + request_body["input"] + ); + + assert_message_role(&request_body["input"][1], "user"); + let user_context_texts = message_input_texts(&request_body["input"][1]); + assert!( + user_context_texts + .iter() + .any(|text| text.starts_with("# AGENTS.md instructions for ")), + "expected AGENTS text in contextual user message, got {user_context_texts:?}" + ); + let ui_text = user_context_texts + .iter() + .copied() + .find(|text| text.contains("")) .expect("invalid message content"); assert!(ui_text.contains("")); assert!(ui_text.contains("be nice")); - assert_message_role(&request_body["input"][3], "user"); - assert_message_starts_with(&request_body["input"][3], ""); - assert_message_ends_with(&request_body["input"][3], ""); + assert!( + user_context_texts + .iter() + .any(|text| text.starts_with("") + && text.ends_with("")), + "expected environment context in contextual user message, got {user_context_texts:?}" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 9e9ce931e..58a7095aa 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -756,16 +756,40 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { let body = requests_payloads[0].body_json(); let input = body.get("input").and_then(|v| v.as_array()).unwrap(); + fn strip_agents_parts_from_user_message( + value: &serde_json::Value, + ) -> Option { + let content = value + .get("content") + .and_then(|content| content.as_array())?; + let filtered_content = content + .iter() + .filter(|item| { + !item + .get("text") + .and_then(|text| text.as_str()) + .is_some_and(|text| text.starts_with("# AGENTS.md instructions for ")) + }) + .cloned() + .collect::>(); + if filtered_content.is_empty() { + return None; + } + let mut normalized = value.clone(); + normalized["content"] = serde_json::Value::Array(filtered_content); + Some(normalized) + } + fn normalize_inputs(values: &[serde_json::Value]) -> Vec { values .iter() - .filter(|value| { + .filter_map(|value| { if value .get("type") .and_then(|ty| ty.as_str()) .is_some_and(|ty| ty == "function_call_output") { - return false; + return None; } let text = value @@ -781,11 +805,13 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { if role == Some("developer") && text.is_some_and(|text| text.contains("`sandbox_mode`")) { - return false; + return None; } - !text.is_some_and(|text| text.starts_with("# AGENTS.md instructions for ")) + if role == Some("user") { + return strip_agents_parts_from_user_message(value); + } + Some(value.clone()) }) - .cloned() .collect() } @@ -3184,7 +3210,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { ]); let mut responses = vec![first_turn]; responses.extend( - (0..6).map(|_| { + (0..5).map(|_| { sse_failed( "compact-failed", "context_length_exceeded", diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 38a8145d5..cc10fa375 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -30,10 +30,17 @@ use pretty_assertions::assert_eq; use tempfile::TempDir; fn text_user_input(text: String) -> serde_json::Value { + text_user_input_parts(vec![text]) +} + +fn text_user_input_parts(texts: Vec) -> serde_json::Value { serde_json::json!({ "type": "message", "role": "user", - "content": [ { "type": "input_text", "text": text } ] + "content": texts + .into_iter() + .map(|text| serde_json::json!({ "type": "input_text", "text": text })) + .collect::>() }) } @@ -297,8 +304,8 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let input1 = body1["input"].as_array().expect("input array"); assert_eq!( input1.len(), - 4, - "expected permissions + cached prefix + env + user msg" + 3, + "expected permissions + cached contextual user prefix + user msg" ); let ui_text = input1[1]["content"][0]["text"] @@ -313,11 +320,11 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let cwd_str = config.cwd.to_string_lossy(); let expected_env_text = default_env_context_str(&cwd_str, &shell); assert_eq!( - input1[2], - text_user_input(expected_env_text), - "expected environment context after UI message" + input1[1]["content"][1]["text"].as_str(), + Some(expected_env_text.as_str()), + "expected environment context bundled after UI message in cached contextual message" ); - assert_eq!(input1[3], text_user_input("hello 1".to_string())); + assert_eq!(input1[2], text_user_input("hello 1".to_string())); let body2 = req2.single_request().body_json(); let input2 = body2["input"].as_array().expect("input array"); @@ -402,8 +409,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let body1 = req1.single_request().body_json(); - let body2 = req2.single_request().body_json(); + let request1 = req1.single_request(); + let request2 = req2.single_request(); + let body1 = request1.body_json(); + let body2 = request2.body_json(); // prompt_cache_key should remain constant across overrides assert_eq!( body1["prompt_cache_key"], body2["prompt_cache_key"], @@ -500,11 +509,14 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul let env_texts: Vec<&str> = input .iter() .filter_map(|msg| { - msg["content"] - .as_array() - .and_then(|content| content.first()) - .and_then(|item| item["text"].as_str()) + msg["content"].as_array().map(|content| { + content + .iter() + .filter_map(|item| item["text"].as_str()) + .collect::>() + }) }) + .flatten() .filter(|text| text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)) .collect(); assert!( @@ -559,11 +571,14 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul let user_texts: Vec<&str> = input .iter() .filter_map(|msg| { - msg["content"] - .as_array() - .and_then(|content| content.first()) - .and_then(|item| item["text"].as_str()) + msg["content"].as_array().map(|content| { + content + .iter() + .filter_map(|item| item["text"].as_str()) + .collect::>() + }) }) + .flatten() .collect(); assert!( user_texts.contains(&"first message"), @@ -639,8 +654,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let body1 = req1.single_request().body_json(); - let body2 = req2.single_request().body_json(); + let request1 = req1.single_request(); + let request2 = req2.single_request(); + let body1 = request1.body_json(); + let body2 = request2.body_json(); // prompt_cache_key should remain constant across per-turn overrides assert_eq!( @@ -672,26 +689,24 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }); let expected_permissions_msg = body1["input"][0].clone(); let body1_input = body1["input"].as_array().expect("input array"); - let expected_model_switch_msg = body2["input"][body1_input.len()].clone(); + let expected_settings_update_msg = body2["input"][body1_input.len()].clone(); + assert_ne!( + expected_settings_update_msg, expected_permissions_msg, + "expected updated permissions message after per-turn override" + ); assert_eq!( - expected_model_switch_msg["role"].as_str(), + expected_settings_update_msg["role"].as_str(), Some("developer") ); assert!( - expected_model_switch_msg["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("")), - "expected model switch message after model override: {expected_model_switch_msg:?}" - ); - let expected_permissions_msg_2 = body2["input"][body1_input.len() + 2].clone(); - assert_ne!( - expected_permissions_msg_2, expected_permissions_msg, - "expected updated permissions message after per-turn override" + request2.has_message_with_input_texts("developer", |texts| { + texts.iter().any(|text| text.contains("")) + }), + "expected model switch section after model override: {expected_settings_update_msg:?}" ); let mut expected_body2 = body1_input.to_vec(); - expected_body2.push(expected_model_switch_msg); + expected_body2.push(expected_settings_update_msg); expected_body2.push(expected_env_msg_2); - expected_body2.push(expected_permissions_msg_2); expected_body2.push(expected_user_message_2); assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); @@ -773,8 +788,10 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let body1 = req1.single_request().body_json(); - let body2 = req2.single_request().body_json(); + let request1 = req1.single_request(); + let request2 = req2.single_request(); + let body1 = request1.body_json(); + let body2 = request2.body_json(); let expected_permissions_msg = body1["input"][0].clone(); let expected_ui_msg = body1["input"][1].clone(); @@ -782,13 +799,18 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let shell = default_user_shell(); let default_cwd_lossy = default_cwd.to_string_lossy(); - let expected_env_msg_1 = text_user_input(default_env_context_str(&default_cwd_lossy, &shell)); + let expected_contextual_user_msg_1 = text_user_input_parts(vec![ + expected_ui_msg["content"][0]["text"] + .as_str() + .expect("cached user instructions text") + .to_string(), + default_env_context_str(&default_cwd_lossy, &shell), + ]); let expected_user_message_1 = text_user_input("hello 1".to_string()); let expected_input_1 = serde_json::Value::Array(vec![ expected_permissions_msg.clone(), - expected_ui_msg.clone(), - expected_env_msg_1.clone(), + expected_contextual_user_msg_1.clone(), expected_user_message_1.clone(), ]); assert_eq!(body1["input"], expected_input_1); @@ -796,8 +818,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_permissions_msg, - expected_ui_msg, - expected_env_msg_1, + expected_contextual_user_msg_1, expected_user_message_1, expected_user_message_2, ]); @@ -881,49 +902,53 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let body1 = req1.single_request().body_json(); - let body2 = req2.single_request().body_json(); + let request1 = req1.single_request(); + let request2 = req2.single_request(); + let body1 = request1.body_json(); + let body2 = request2.body_json(); let expected_permissions_msg = body1["input"][0].clone(); let expected_ui_msg = body1["input"][1].clone(); let shell = default_user_shell(); let expected_env_text_1 = default_env_context_str(&default_cwd.to_string_lossy(), &shell); - let expected_env_msg_1 = text_user_input(expected_env_text_1); + let expected_contextual_user_msg_1 = text_user_input_parts(vec![ + expected_ui_msg["content"][0]["text"] + .as_str() + .expect("cached user instructions text") + .to_string(), + expected_env_text_1, + ]); let expected_user_message_1 = text_user_input("hello 1".to_string()); let expected_input_1 = serde_json::Value::Array(vec![ expected_permissions_msg.clone(), - expected_ui_msg.clone(), - expected_env_msg_1.clone(), + expected_contextual_user_msg_1.clone(), expected_user_message_1.clone(), ]); assert_eq!(body1["input"], expected_input_1); let body1_input = body1["input"].as_array().expect("input array"); - let expected_model_switch_msg = body2["input"][body1_input.len()].clone(); + let expected_settings_update_msg = body2["input"][body1_input.len()].clone(); + assert_ne!( + expected_settings_update_msg, expected_permissions_msg, + "expected updated permissions message after policy change" + ); assert_eq!( - expected_model_switch_msg["role"].as_str(), + expected_settings_update_msg["role"].as_str(), Some("developer") ); assert!( - expected_model_switch_msg["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("")), - "expected model switch message after model override: {expected_model_switch_msg:?}" - ); - let expected_permissions_msg_2 = body2["input"][body1_input.len() + 1].clone(); - assert_ne!( - expected_permissions_msg_2, expected_permissions_msg, - "expected updated permissions message after policy change" + request2.has_message_with_input_texts("developer", |texts| { + texts.iter().any(|text| text.contains("")) + }), + "expected model switch section after model override: {expected_settings_update_msg:?}" ); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_permissions_msg, - expected_ui_msg, - expected_env_msg_1, + expected_contextual_user_msg_1, expected_user_message_1, - expected_model_switch_msg, - expected_permissions_msg_2, + expected_settings_update_msg, expected_user_message_2, ]); assert_eq!(body2["input"], expected_input_2); diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index a12398144..d4b8ba80a 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -605,7 +605,9 @@ async fn review_input_isolated_from_parent_history() { let env_text = input .iter() - .filter_map(|msg| msg["content"][0]["text"].as_str()) + .filter_map(|msg| msg.get("content").and_then(|content| content.as_array())) + .flat_map(|content| content.iter()) + .filter_map(|entry| entry.get("text").and_then(|text| text.as_str())) .find(|text| text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)) .expect("env text"); assert!( @@ -615,7 +617,9 @@ async fn review_input_isolated_from_parent_history() { let review_text = input .iter() - .filter_map(|msg| msg["content"][0]["text"].as_str()) + .filter_map(|msg| msg.get("content").and_then(|content| content.as_array())) + .flat_map(|content| content.iter()) + .filter_map(|entry| entry.get("text").and_then(|text| text.as_str())) .find(|text| *text == review_prompt) .expect("review prompt text"); assert_eq!( diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap index c88ff3312..e15d55aab 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap @@ -6,16 +6,18 @@ Scenario: Manual /compact with prior user history compacts existing history and ## Local Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:first manual turn -04:message/assistant:FIRST_REPLY -05:message/user: +01:message/user[2]: + [01] + [02] > +02:message/user:first manual turn +03:message/assistant:FIRST_REPLY +04:message/user: ## Local Post-Compaction History Layout 00:message/user:first manual turn 01:message/user:\nFIRST_MANUAL_SUMMARY 02:message/developer: -03:message/user: -04:message/user:> -05:message/user:second manual turn +03:message/user[2]: + [01] + [02] > +04:message/user:second manual turn diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap index 0c8f87d04..ca07006ea 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap @@ -6,13 +6,15 @@ Scenario: Manual /compact with no prior user turn currently still issues a compa ## Local Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user: +01:message/user[2]: + [01] + [02] > +02:message/user: ## Local Post-Compaction History Layout 00:message/user:\nMANUAL_EMPTY_SUMMARY 01:message/developer: -02:message/user: -03:message/user:> -04:message/user:AFTER_MANUAL_EMPTY_COMPACT +02:message/user[2]: + [01] + [02] > +03:message/user:AFTER_MANUAL_EMPTY_COMPACT diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap index c13f78aff..f59fdf4b9 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap @@ -1,22 +1,23 @@ --- source: core/tests/suite/compact.rs -assertion_line: 2646 expression: "format_labeled_requests_snapshot(\"True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])" --- Scenario: True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn. ## Local Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:function call limit push -04:function_call/test_tool -05:function_call_output:unsupported call: test_tool -06:message/user: +01:message/user[2]: + [01] + [02] > +02:message/user:function call limit push +03:function_call/test_tool +04:function_call_output:unsupported call: test_tool +05:message/user: ## Local Post-Compaction History Layout 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:function call limit push -04:message/user:\nAUTO_SUMMARY +01:message/user[2]: + [01] + [02] > +02:message/user:function call limit push +03:message/user:\nAUTO_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap index 552c117c9..7f61d7ed5 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap @@ -1,30 +1,35 @@ --- source: core/tests/suite/compact.rs +assertion_line: 1791 expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" --- Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. ## Initial Request (Previous Model) 00:message/developer: -01:message/user: -02:message/user:> -03:message/developer: -04:message/user:before switch +01:message/user[2]: + [01] + [02] > +02:message/developer: +03:message/user:before switch ## Pre-sampling Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/developer: -04:message/user:before switch -05:message/assistant:before switch -06:message/user: +01:message/user[2]: + [01] + [02] > +02:message/developer: +03:message/user:before switch +04:message/assistant:before switch +05:message/user: ## Post-Compaction Follow-up Request (Next Model) 00:message/user:before switch 01:message/user:\nPRE_SAMPLING_SUMMARY -02:message/developer:\nThe user was previously using a different model.... -03:message/developer: -04:message/user: -05:message/user:> -06:message/user:after switch +02:message/developer[2]: + [01] \nThe user was previously using a different model.... + [02] +03:message/user[2]: + [01] + [02] > +04:message/user:after switch diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap index da30b01bd..0de8baeeb 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap @@ -6,8 +6,9 @@ Scenario: Pre-turn auto-compaction context-window failure: compaction request ex ## Local Compaction Request (Incoming User Excluded) 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/assistant:FIRST_REPLY -05:message/user: +01:message/user[2]: + [01] + [02] > +02:message/user:USER_ONE +03:message/assistant:FIRST_REPLY +04:message/user: diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index ea4fea2f2..8712df583 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -6,19 +6,25 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif ## Local Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/assistant:FIRST_REPLY -05:message/user:USER_TWO -06:message/assistant:SECOND_REPLY -07:message/user: +01:message/user[2]: + [01] + [02] > +02:message/user:USER_ONE +03:message/assistant:FIRST_REPLY +04:message/user:USER_TWO +05:message/assistant:SECOND_REPLY +06:message/user: ## Local Post-Compaction History Layout 00:message/user:USER_ONE 01:message/user:USER_TWO 02:message/user:\nPRE_TURN_SUMMARY 03:message/developer: -04:message/user: -05:message/user: -06:message/user: | | | USER_THREE +04:message/user[2]: + [01] + [02] +05:message/user[4]: + [01] + [02] + [03] + [04] USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 59d3785a4..46d76bb10 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,31 +1,36 @@ --- source: core/tests/suite/compact.rs +assertion_line: 3188 expression: "format_labeled_requests_snapshot(\"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request. ## Initial Request (Previous Model) 00:message/developer: -01:message/user: -02:message/user:> -03:message/developer: -04:message/user:BEFORE_SWITCH_USER +01:message/user[2]: + [01] + [02] > +02:message/developer: +03:message/user:BEFORE_SWITCH_USER ## Local Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/developer: -04:message/user:BEFORE_SWITCH_USER -05:message/assistant:BEFORE_SWITCH_REPLY -06:message/user: +01:message/user[2]: + [01] + [02] > +02:message/developer: +03:message/user:BEFORE_SWITCH_USER +04:message/assistant:BEFORE_SWITCH_REPLY +05:message/user: ## Local Post-Compaction History Layout 00:message/user:BEFORE_SWITCH_USER 01:message/user:\nPRETURN_SWITCH_SUMMARY -02:message/developer:\nThe user was previously using a different model.... -03:message/developer: -04:message/developer: The user has requested a new communication st... -05:message/user: -06:message/user:> -07:message/user:AFTER_SWITCH_USER +02:message/developer[3]: + [01] \nThe user was previously using a different model.... + [02] + [03] The user has requested a new communication st... +03:message/user[2]: + [01] + [02] > +04:message/user:AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap index 701e67676..83bec30fb 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap @@ -6,14 +6,16 @@ Scenario: Remote manual /compact where remote compact output is compaction-only: ## Remote Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:hello remote compact -04:message/assistant:FIRST_REMOTE_REPLY +01:message/user[2]: + [01] + [02] > +02:message/user:hello remote compact +03:message/assistant:FIRST_REMOTE_REPLY ## Remote Post-Compaction History Layout 00:compaction:encrypted=true 01:message/developer: -02:message/user: -03:message/user:> -04:message/user:after compact +02:message/user[2]: + [01] + [02] > +03:message/user:after compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap index ce62806d9..6ec8149c0 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap @@ -6,11 +6,13 @@ Scenario: Remote manual /compact with no prior user turn still issues a compact ## Remote Compaction Request 00:message/developer: -01:message/user: -02:message/user:> +01:message/user[2]: + [01] + [02] > ## Remote Post-Compaction History Layout 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE +01:message/user[2]: + [01] + [02] > +02:message/user:USER_ONE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap index 7d5bcde90..ccc5a5581 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact_remote.rs -assertion_line: 1876 expression: "format_labeled_requests_snapshot(\"After a prior manual /compact produced an older remote compaction item, the next turn hits remote auto-compaction before the next sampling request. The compact request carries forward that earlier compaction item, and the next sampling request shows the latest compaction item with context reinjected before USER_TWO.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Second Turn Request (After Compaction)\", &second_turn_request),])" --- Scenario: After a prior manual /compact produced an older remote compaction item, the next turn hits remote auto-compaction before the next sampling request. The compact request carries forward that earlier compaction item, and the next sampling request shows the latest compaction item with context reinjected before USER_TWO. @@ -13,6 +12,7 @@ Scenario: After a prior manual /compact produced an older remote compaction item 00:message/user:USER_ONE 01:compaction:encrypted=true 02:message/developer: -03:message/user: -04:message/user:> -05:message/user:USER_TWO +03:message/user[2]: + [01] + [02] > +04:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap index f5533b94a..8e3e4235f 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap @@ -6,15 +6,17 @@ Scenario: Remote mid-turn continuation compaction after tool output: compact req ## Remote Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:function_call/test_tool -05:function_call_output:unsupported call: test_tool +01:message/user[2]: + [01] + [02] > +02:message/user:USER_ONE +03:function_call/test_tool +04:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:compaction:encrypted=true +01:message/user[2]: + [01] + [02] > +02:message/user:USER_ONE +03:compaction:encrypted=true diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap index 3f8c77e97..0f5886be1 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap @@ -1,19 +1,21 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only a compaction item: continuation layout reinjects context before that compaction item.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only a compaction item: continuation layout reinjects context before that compaction item.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- Scenario: Remote mid-turn compaction where compact output has only a compaction item: continuation layout reinjects context before that compaction item. ## Remote Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:function_call/test_tool -05:function_call_output:unsupported call: test_tool +01:message/user[2]: + [01] + [02] > +02:message/user:USER_ONE +03:function_call/test_tool +04:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout 00:message/developer: -01:message/user: -02:message/user:> -03:compaction:encrypted=true +01:message/user[2]: + [01] + [02] > +02:compaction:encrypted=true diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap index e718bfda8..88e5c0bd2 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap @@ -6,7 +6,8 @@ Scenario: Remote pre-turn auto-compaction context-window failure: compaction req ## Remote Compaction Request (Incoming User Excluded) 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/assistant:REMOTE_FIRST_REPLY +01:message/user[2]: + [01] + [02] > +02:message/user:USER_ONE +03:message/assistant:REMOTE_FIRST_REPLY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap index f8aa74e9a..224f6dbba 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap @@ -6,7 +6,8 @@ Scenario: Remote pre-turn auto-compaction parse failure: compaction request excl ## Remote Compaction Request (Incoming User Excluded) 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:turn that exceeds token threshold -04:message/assistant:initial turn complete +01:message/user[2]: + [01] + [02] > +02:message/user:turn that exceeds token threshold +03:message/assistant:initial turn complete diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap index 2236984a6..5a6f270d3 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -6,18 +6,20 @@ Scenario: Remote pre-turn auto-compaction with a context override emits the cont ## Remote Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/assistant:REMOTE_FIRST_REPLY -05:message/user:USER_TWO -06:message/assistant:REMOTE_SECOND_REPLY +01:message/user[2]: + [01] + [02] > +02:message/user:USER_ONE +03:message/assistant:REMOTE_FIRST_REPLY +04:message/user:USER_TWO +05:message/assistant:REMOTE_SECOND_REPLY ## Remote Post-Compaction History Layout 00:message/user:USER_ONE 01:message/user:USER_TWO 02:compaction:encrypted=true 03:message/developer: -04:message/user: -05:message/user: -06:message/user:USER_THREE +04:message/user[2]: + [01] + [02] +05:message/user:USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 15beec5f2..ebab84f4e 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,28 +1,33 @@ --- source: core/tests/suite/compact_remote.rs +assertion_line: 1514 expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &initial_turn_request),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- Scenario: Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request. ## Initial Request (Previous Model) 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:BEFORE_SWITCH_USER +01:message/user[2]: + [01] + [02] > +02:message/user:BEFORE_SWITCH_USER ## Remote Compaction Request 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:BEFORE_SWITCH_USER -04:message/assistant:BEFORE_SWITCH_REPLY +01:message/user[2]: + [01] + [02] > +02:message/user:BEFORE_SWITCH_USER +03:message/assistant:BEFORE_SWITCH_REPLY ## Remote Post-Compaction History Layout 00:message/user:BEFORE_SWITCH_USER 01:compaction:encrypted=true -02:message/developer:\nThe user was previously using a different model.... -03:message/developer: -04:message/developer: The user has requested a new communication st... -05:message/user: -06:message/user:> -07:message/user:AFTER_SWITCH_USER +02:message/developer[3]: + [01] \nThe user was previously using a different model.... + [02] + [03] The user has requested a new communication st... +03:message/user[2]: + [01] + [02] > +04:message/user:AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_does_not_refresh_agents.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_does_not_refresh_agents.snap index 9b6291809..65dffc556 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_does_not_refresh_agents.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_does_not_refresh_agents.snap @@ -6,19 +6,21 @@ Scenario: Second turn changes cwd to a directory with different AGENTS.md; curre ## First Request (agents_one) 00:message/developer: -01:message/user: -02:message/user:> +01:message/user[2]: + [01] + [02] > +02:message/developer: 03:message/user:> -04:message/developer: -05:message/user:first turn in agents_one +04:message/user:first turn in agents_one ## Second Request (agents_two cwd) 00:message/developer: -01:message/user: -02:message/user:> +01:message/user[2]: + [01] + [02] > +02:message/developer: 03:message/user:> -04:message/developer: -05:message/user:first turn in agents_one -06:message/assistant:turn one complete -07:message/user:> -08:message/user:second turn in agents_two +04:message/user:first turn in agents_one +05:message/assistant:turn one complete +06:message/user:> +07:message/user:second turn in agents_two diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap index 06269eff7..045e97706 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap @@ -1,21 +1,22 @@ --- source: core/tests/suite/model_visible_layout.rs -assertion_line: 435 expression: "format_labeled_requests_snapshot(\"First post-resume turn where pre-turn override sets model to rollout model; no model-switch update should appear.\",\n&[(\"Last Request Before Resume\", &initial_request),\n(\"First Request After Resume + Override\", &resumed_request),])" --- Scenario: First post-resume turn where pre-turn override sets model to rollout model; no model-switch update should appear. ## Last Request Before Resume 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:seed resume history +01:message/user[2]: + [01] + [02] > +02:message/user:seed resume history ## First Request After Resume + Override 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:seed resume history -04:message/assistant:recorded before resume -05:message/user: -06:message/user:first resumed turn after model override +01:message/user[2]: + [01] + [02] > +02:message/user:seed resume history +03:message/assistant:recorded before resume +04:message/user: +05:message/user:first resumed turn after model override diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap index 54417c6de..3918fafa6 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap @@ -1,23 +1,25 @@ --- source: core/tests/suite/model_visible_layout.rs -assertion_line: 337 expression: "format_labeled_requests_snapshot(\"First post-resume turn where resumed config model differs from rollout and personality changes.\",\n&[(\"Last Request Before Resume\", &initial_request),\n(\"First Request After Resume\", &resumed_request),])" --- Scenario: First post-resume turn where resumed config model differs from rollout and personality changes. ## Last Request Before Resume 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:seed resume history +01:message/user[2]: + [01] + [02] > +02:message/user:seed resume history ## First Request After Resume 00:message/developer: -01:message/user: -02:message/user:> -03:message/user:seed resume history -04:message/assistant:recorded before resume -05:message/developer:\nThe user was previously using a different model. Please continue the conversatio... -06:message/user: -07:message/developer: -08:message/user:resume and change personality +01:message/user[2]: + [01] + [02] > +02:message/user:seed resume history +03:message/assistant:recorded before resume +04:message/developer[2]: + [01] \nThe user was previously using a different model. Please continue the conversatio... + [02] +05:message/user: +06:message/user:resume and change personality diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_turn_overrides.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_turn_overrides.snap index 9ec0b2e7d..2172d7399 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_turn_overrides.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_turn_overrides.snap @@ -6,19 +6,22 @@ Scenario: Second turn changes cwd, approval policy, and personality while keepin ## First Request (Baseline) 00:message/developer: -01:message/user: -02:message/user:> -03:message/developer: -04:message/user:first turn +01:message/user[2]: + [01] + [02] > +02:message/developer: +03:message/user:first turn ## Second Request (Turn Overrides) 00:message/developer: -01:message/user: -02:message/user:> -03:message/developer: -04:message/user:first turn -05:message/assistant:turn one complete +01:message/user[2]: + [01] + [02] > +02:message/developer: +03:message/user:first turn +04:message/assistant:turn one complete +05:message/developer[2]: + [01] + [02] The user has requested a new communication style. Future messages should adhe... 06:message/user: -07:message/developer: -08:message/developer: The user has requested a new communication style. Future messages should adhe... -09:message/user:second turn with context updates +07:message/user:second turn with context updates diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 8cd8fffb4..a85183620 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -321,6 +321,9 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { let resumed_path_cwd = find_session_file_containing_marker(&sessions_dir, &marker_a2) .expect("no resumed session file containing marker_a2"); + // The `--all` resume above appends a new turn to `path_b` while running from `dir_a`, so the + // session's latest cwd now matches `dir_a`. A subsequent `resume --last` should therefore pick + // the newest matching session (`path_b`). assert_eq!( resumed_path_cwd, path_b, "resume --last should prefer sessions whose latest turn context matches the current cwd"