From 82b294c73c902a4c51f789ba68bb599f0065616f Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 18 Jul 2026 02:41:14 +0000 Subject: [PATCH] Track permission instructions in world state (#33944) ## What changed - Model permission instructions as a world-state section keyed by a stable hash of their rendered developer message. - Re-emit permission context when its model-visible contents change or the retained fragment is missing, while avoiding duplicates when matching instructions already exist in history, including bundled developer messages. - Normalize CRLF line endings when hashing world-state fragments so equivalent content produces the same snapshot. ## Testing - Add snapshot coverage for unchanged, changed, absent, and unknown permission state. - Cover bundled retained permissions and restored world-state baselines during rollout reconstruction. GitOrigin-RevId: 7d8d212460c1690ca617859f66dcd825987f1e23 --- codex-rs/core/src/context/world_state/mod.rs | 25 +++++ .../src/context/world_state/permissions.rs | 80 ++++++++++++++++ .../context/world_state/permissions_tests.rs | 94 +++++++++++++++++++ ..._state__permissions__tests__snapshots.snap | 33 +++++++ .../context/world_state/world_state_tests.rs | 8 ++ codex-rs/core/src/context_manager/updates.rs | 52 ---------- codex-rs/core/src/session/mod.rs | 39 +------- .../session/rollout_reconstruction_tests.rs | 26 +++-- ..._startup_context_then_first_turn_diff.snap | 7 +- codex-rs/core/src/session/tests.rs | 14 ++- codex-rs/core/src/session/world_state.rs | 27 ++++++ codex-rs/core/tests/suite/client.rs | 41 ++++---- codex-rs/core/tests/suite/compact.rs | 12 ++- codex-rs/core/tests/suite/prompt_caching.rs | 46 +++++---- ...ompact_restates_realtime_start_shapes.snap | 8 +- ..._does_not_restate_realtime_end_shapes.snap | 8 +- ...action_restates_realtime_start_shapes.snap | 8 +- ...le_layout_cwd_change_refreshes_agents.snap | 8 +- ...resume_override_matches_rollout_model.snap | 8 +- ...layout_resume_with_personality_change.snap | 17 ++-- ...__model_visible_layout_turn_overrides.snap | 17 ++-- ..._new_context_window_tool_full_context.snap | 7 +- 22 files changed, 396 insertions(+), 189 deletions(-) create mode 100644 codex-rs/core/src/context/world_state/permissions.rs create mode 100644 codex-rs/core/src/context/world_state/permissions_tests.rs create mode 100644 codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__permissions__tests__snapshots.snap diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index c47fba8446..1d407ab77e 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -3,6 +3,7 @@ mod apps_instructions; mod collaboration_mode; mod environment; mod environments_instructions; +mod permissions; mod plugins_instructions; mod realtime; #[cfg(test)] @@ -19,6 +20,8 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Map; use serde_json::Value; +use sha1::Digest; +use sha1::Sha1; use std::collections::BTreeMap; use std::fmt; @@ -27,6 +30,7 @@ pub(crate) use apps_instructions::AppsInstructionsState; pub(crate) use collaboration_mode::CollaborationModeState; pub(crate) use environment::EnvironmentsState; pub(crate) use environments_instructions::EnvironmentsInstructionsState; +pub(crate) use permissions::PermissionsState; pub(crate) use plugins_instructions::PluginsInstructionsState; pub(crate) use realtime::RealtimeState; @@ -211,6 +215,27 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static { ) -> Option>; } +/// Stable fingerprint of a model-visible World State fragment. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(transparent)] +pub(crate) struct WorldStateHash(String); + +impl WorldStateHash { + pub(crate) fn from_fragment(fragment: &(impl ContextualUserFragment + ?Sized)) -> Self { + let mut hasher = Sha1::new(); + hasher.update(b"codex-world-state-fragment-v1\0"); + hash_component(&mut hasher, fragment.role()); + hash_component(&mut hasher, &fragment.render()); + Self(format!("{:x}", hasher.finalize())) + } +} + +fn hash_component(hasher: &mut Sha1, value: &str) { + let value = value.replace("\r\n", "\n"); + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value.as_bytes()); +} + /// Live model-visible state, keyed by the same stable section IDs used in rollouts. #[derive(Default)] pub(crate) struct WorldState { diff --git a/codex-rs/core/src/context/world_state/permissions.rs b/codex-rs/core/src/context/world_state/permissions.rs new file mode 100644 index 0000000000..ae21469a7f --- /dev/null +++ b/codex-rs/core/src/context/world_state/permissions.rs @@ -0,0 +1,80 @@ +use super::PreviousSectionState; +use super::WorldStateHash; +use super::WorldStateSection; +use crate::context::ApprovalPromptContext; +use crate::context::ContextualUserFragment; +use crate::context::PermissionsInstructions; +use codex_execpolicy::Policy; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use std::path::Path; + +/// Permission instructions currently visible to the model. +#[derive(Clone, Debug)] +pub(crate) struct PermissionsState { + snapshot: WorldStateHash, + instructions: PermissionsInstructions, +} + +impl PermissionsState { + pub(crate) fn new( + permission_profile: &PermissionProfile, + approval_policy: AskForApproval, + approval_context: ApprovalPromptContext<'_>, + exec_policy: &Policy, + cwd: &Path, + exec_permission_approvals_enabled: bool, + request_permissions_tool_enabled: bool, + ) -> Self { + let instructions = PermissionsInstructions::from_permission_profile( + permission_profile, + approval_policy, + approval_context, + exec_policy, + cwd, + exec_permission_approvals_enabled, + request_permissions_tool_enabled, + ); + let snapshot = WorldStateHash::from_fragment(&instructions); + Self { + snapshot, + instructions, + } + } +} + +impl WorldStateSection for PermissionsState { + const ID: &'static str = "permissions"; + type Snapshot = WorldStateHash; + + fn snapshot(&self) -> Self::Snapshot { + self.snapshot.clone() + } + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + role == "developer" && PermissionsInstructions::matches_text(text) + } + + fn has_retained_fragment_matcher() -> bool { + true + } + + fn matches_retained_fragment(role: &str, text: &str) -> bool { + Self::matches_legacy_fragment(role, text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + if matches!(previous, PreviousSectionState::Known(previous) if previous == &self.snapshot) { + return None; + } + + Some(Box::new(self.instructions.clone())) + } +} + +#[cfg(test)] +#[path = "permissions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/world_state/permissions_tests.rs b/codex-rs/core/src/context/world_state/permissions_tests.rs new file mode 100644 index 0000000000..769b9a472a --- /dev/null +++ b/codex-rs/core/src/context/world_state/permissions_tests.rs @@ -0,0 +1,94 @@ +use super::*; +use crate::context::world_state::test_support::render_section_cases; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::ContentItem; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ApprovalMessages; +use codex_protocol::openai_models::PermissionMessages; +use codex_protocol::protocol::AskForApproval; +use pretty_assertions::assert_eq; +use std::path::Path; + +#[test] +fn snapshots() { + use PreviousSectionState::Absent; + use PreviousSectionState::Known; + use PreviousSectionState::Unknown; + + let read_only = permissions_state(PermissionProfile::read_only(), AskForApproval::OnRequest); + let full_access = permissions_state(PermissionProfile::Disabled, AskForApproval::OnRequest); + let never_ask = permissions_state(PermissionProfile::read_only(), AskForApproval::Never); + + insta::assert_snapshot!(render_section_cases(&[ + (Absent, Absent), + (Absent, Known(&read_only)), + (Known(&read_only), Known(&read_only)), + (Known(&read_only), Known(&full_access)), + (Known(&read_only), Known(&never_ask)), + (Unknown, Known(&read_only)), + ])); +} + +#[test] +fn persisted_permissions_are_detected_inside_bundled_developer_messages() { + let state = permissions_state(PermissionProfile::read_only(), AskForApproval::OnRequest); + let retained = ContextualUserFragment::into(state.instructions.clone()); + let mut world_state = super::super::WorldState::default(); + world_state.add_section(state); + let snapshot = world_state.snapshot(); + let mut bundled_retained = retained.clone(); + let ResponseItem::Message { content, .. } = &mut bundled_retained else { + panic!("permissions should render as a message"); + }; + content.insert( + 0, + ContentItem::InputText { + text: "Other developer instructions.".to_string(), + }, + ); + + assert_eq!( + world_state + .render_history_diff(/*previous*/ None, std::slice::from_ref(&retained)) + .len(), + 1, + ); + assert_eq!( + world_state.render_history_diff(Some(&snapshot), &[]).len(), + 1, + ); + assert!( + world_state + .render_history_diff(Some(&snapshot), &[bundled_retained]) + .is_empty() + ); +} + +fn permissions_state( + permission_profile: PermissionProfile, + approval_policy: AskForApproval, +) -> PermissionsState { + let approval_messages = ApprovalMessages { + on_request: Some("Ask for approval.".to_string()), + on_request_auto_review: None, + }; + let permission_messages = PermissionMessages { + danger_full_access: Some("Full access.".to_string()), + workspace_write: Some("Workspace write.".to_string()), + read_only: Some("Read only.".to_string()), + }; + PermissionsState::new( + &permission_profile, + approval_policy, + ApprovalPromptContext::new( + ApprovalsReviewer::User, + Some(&approval_messages), + Some(&permission_messages), + ), + &Policy::empty(), + Path::new("/workspace"), + /*exec_permission_approvals_enabled*/ false, + /*request_permissions_tool_enabled*/ false, + ) +} diff --git a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__permissions__tests__snapshots.snap b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__permissions__tests__snapshots.snap new file mode 100644 index 0000000000..caa912ecce --- /dev/null +++ b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__permissions__tests__snapshots.snap @@ -0,0 +1,33 @@ +--- +source: core/src/context/world_state/permissions_tests.rs +expression: "render_section_cases(&[(Absent, Absent), (Absent, Known(&read_only)),\n(Known(&read_only), Known(&read_only)),\n(Known(&read_only), Known(&full_access)),\n(Known(&read_only), Known(&never_ask)), (Unknown, Known(&read_only)),])" +--- +Absent -> Absent +None + +Absent -> "0ccde536dd8b4cfebb1df573f679dfe86b5718e3" (role - developer) + +Read only. +Ask for approval. + + +"0ccde536dd8b4cfebb1df573f679dfe86b5718e3" -> "0ccde536dd8b4cfebb1df573f679dfe86b5718e3" +None + +"0ccde536dd8b4cfebb1df573f679dfe86b5718e3" -> "7e17d9a0df61e5f0c032ed7f0bf1dda58c9d5e85" (role - developer) + +Full access. +Ask for approval. + + +"0ccde536dd8b4cfebb1df573f679dfe86b5718e3" -> "d96f228104cb899a8d3788b08e1d64b3bbb6ac84" (role - developer) + +Read only. +Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected. + + +Unknown -> "0ccde536dd8b4cfebb1df573f679dfe86b5718e3" (role - developer) + +Read only. +Ask for approval. + diff --git a/codex-rs/core/src/context/world_state/world_state_tests.rs b/codex-rs/core/src/context/world_state/world_state_tests.rs index 9432672423..97e1ead64e 100644 --- a/codex-rs/core/src/context/world_state/world_state_tests.rs +++ b/codex-rs/core/src/context/world_state/world_state_tests.rs @@ -53,6 +53,14 @@ impl ContextualUserFragment for TestFragment { } } +#[test] +fn world_state_hash_normalizes_crlf_line_endings() { + assert_eq!( + WorldStateHash::from_fragment(&TestFragment("line one\r\nline two".to_string())), + WorldStateHash::from_fragment(&TestFragment("line one\nline two".to_string())), + ); +} + struct DuplicateTestSection; impl WorldStateSection for DuplicateTestSection { diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index d7e1c47b68..54ceb3c922 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,13 +1,9 @@ -use crate::context::ApprovalPromptContext; use crate::context::ContextualUserFragment; use crate::context::ModelSwitchInstructions; use crate::context::MultiAgentModeInstructions; -use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; use crate::session::PreviousTurnSettings; use crate::session::turn_context::TurnContext; -use codex_execpolicy::Policy; -use codex_features::Feature; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; @@ -15,52 +11,6 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::TurnContextItem; -fn build_permissions_update_item( - previous: Option<&TurnContextItem>, - next: &TurnContext, - exec_policy: &Policy, -) -> Option { - if !next.config.include_permissions_instructions { - return None; - } - - let prev = previous?; - if prev.permission_profile() == next.permission_profile() - && prev.approval_policy == next.approval_policy.value() - && prev.model == next.model_info.slug - { - return None; - } - - Some( - PermissionsInstructions::from_permission_profile( - &next.permission_profile, - next.approval_policy.value(), - ApprovalPromptContext::new( - next.config.approvals_reviewer, - next.model_info - .model_messages - .as_ref() - .and_then(|messages| messages.approvals.as_ref()), - next.model_info - .model_messages - .as_ref() - .and_then(|messages| messages.permissions.as_ref()), - ), - exec_policy, - #[allow(deprecated)] - &next.cwd, - next.config - .features - .enabled(Feature::ExecPermissionApprovals), - next.config - .features - .enabled(Feature::RequestPermissionsTool), - ) - .render(), - ) -} - fn build_multi_agent_mode_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, @@ -185,7 +135,6 @@ pub(crate) fn build_settings_update_items( previous: Option<&TurnContextItem>, previous_turn_settings: Option<&PreviousTurnSettings>, next: &TurnContext, - exec_policy: &Policy, personality_feature_enabled: bool, ) -> Vec { // TODO(ccunningham): build_settings_update_items still does not cover every @@ -196,7 +145,6 @@ pub(crate) fn build_settings_update_items( // Keep model-switch instructions first so model-specific guidance is read before // any other context diffs on this turn. build_model_instructions_update_item(previous_turn_settings, next), - build_permissions_update_item(previous, next, exec_policy), build_multi_agent_mode_update_item(previous, next), build_personality_update_item(previous, next, personality_feature_enabled), ] diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f08cb036dc..d434666594 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -22,13 +22,11 @@ use crate::build_available_skills; use crate::compact; use crate::config::ManagedFeatures; use crate::config::resolve_tool_suggest_config_from_layer_stack; -use crate::context::ApprovalPromptContext; use crate::context::ApprovedCommandPrefixSaved; use crate::context::AvailableSkillsInstructions; use crate::context::ContextualUserFragment; use crate::context::MultiAgentModeInstructions; use crate::context::NetworkRuleSaved; -use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; use crate::context::RecommendedPluginsInstructions; use crate::context::world_state::WorldState; @@ -1702,18 +1700,16 @@ impl Session { ) -> Vec { // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible - // context (exec policy, feature gates, previous-turn bridge) should be persisted + // context (feature gates and the previous-turn bridge) should be persisted // state or explicit non-state replay events. let previous_turn_settings = { let state = self.state.lock().await; state.previous_turn_settings() }; - let exec_policy = self.services.exec_policy.current(); crate::context_manager::updates::build_settings_update_items( reference_context_item, previous_turn_settings.as_ref(), current_context, - exec_policy.as_ref(), self.features.enabled(Feature::Personality), ) } @@ -3213,39 +3209,6 @@ impl Session { { developer_sections.push(model_switch_message); } - if turn_context.config.include_permissions_instructions { - developer_sections.push( - PermissionsInstructions::from_permission_profile( - &turn_context.permission_profile, - turn_context.approval_policy.value(), - ApprovalPromptContext::new( - turn_context.config.approvals_reviewer, - turn_context - .model_info - .model_messages - .as_ref() - .and_then(|messages| messages.approvals.as_ref()), - turn_context - .model_info - .model_messages - .as_ref() - .and_then(|messages| messages.permissions.as_ref()), - ), - self.services.exec_policy.current().as_ref(), - #[allow(deprecated)] - &turn_context.cwd, - turn_context - .config - .features - .enabled(Feature::ExecPermissionApprovals), - turn_context - .config - .features - .enabled(Feature::RequestPermissionsTool), - ) - .render(), - ); - } let separate_guardian_developer_message = crate::guardian::is_guardian_reviewer_source(&session_source); // Keep the guardian policy prompt out of the aggregated developer bundle so it diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index e420224117..f5bf358d5a 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -139,12 +139,21 @@ async fn record_initial_history_restores_world_state_baseline() { let (session, turn_context) = make_session_and_context().await; let turn_context = Arc::new(turn_context); let world_state = build_world_state_from_turn_context(&session, &turn_context).await; - let rollout_items = completed_user_turn_rollout( - turn_context.to_turn_context_item(), - vec![RolloutItem::WorldState(WorldStateItem::full( - world_state.snapshot().into_value(), - ))], - ); + let expected_history = world_state + .render_full() + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(); + let mut world_state_items = expected_history + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect::>(); + world_state_items.push(RolloutItem::WorldState(WorldStateItem::full( + world_state.snapshot().into_value(), + ))); + let rollout_items = + completed_user_turn_rollout(turn_context.to_turn_context_item(), world_state_items); session .record_initial_history(InitialHistory::Resumed(ResumedHistory { @@ -158,7 +167,10 @@ async fn record_initial_history_restores_world_state_baseline() { .record_context_updates_and_set_reference_context_item(&step_context) .await; - assert_eq!(session.clone_history().await.raw_items(), &[]); + assert_eq!( + session.clone_history().await.raw_items(), + expected_history.as_slice(), + ); } #[tokio::test] diff --git a/codex-rs/core/src/session/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap b/codex-rs/core/src/session/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap index 44df93301a..c51cb93ac6 100644 --- a/codex-rs/core/src/session/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap +++ b/codex-rs/core/src/session/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap @@ -8,6 +8,7 @@ Scenario: First request after fork when startup preserves the parent baseline, t 00:message/developer: 01:message/user:> 02:message/user:fork seed -03:message/developer: -04:message/developer:Fork turn collaboration instructions. -05:message/user:after fork +03:message/developer[2]: + [01] + [02] Fork turn collaboration instructions. +04:message/user:after fork diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index fcfe649678..f80fd944d4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8913,12 +8913,22 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline let previous_context_item = turn_context.to_turn_context_item(); let previous_context = Arc::new(turn_context); let world_state = build_world_state_from_turn_context(&session, &previous_context).await; + let retained_world_state = world_state + .render_full() + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(); + session + .replace_history( + retained_world_state.clone(), + Some(previous_context_item.clone()), + ) + .await; let mut turn_context = Arc::try_unwrap(previous_context) .unwrap_or_else(|_| panic!("previous turn context should have no remaining references")); turn_context.sub_id = format!("{}-next", turn_context.sub_id); { let mut state = session.state.lock().await; - state.set_reference_context_item(Some(previous_context_item.clone())); state .history .set_world_state_baseline(world_state.snapshot()); @@ -8938,7 +8948,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline assert_eq!( session.clone_history().await.raw_items().to_vec(), - Vec::new() + retained_world_state ); assert_eq!( serde_json::to_value(session.reference_context_item().await) diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 636150c767..28ca491486 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -1,11 +1,13 @@ use super::session::Session; use super::step_context::StepContext; use crate::connectors; +use crate::context::ApprovalPromptContext; use crate::context::world_state::AgentsMdState; use crate::context::world_state::AppsInstructionsState; use crate::context::world_state::CollaborationModeState; use crate::context::world_state::EnvironmentsInstructionsState; use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::PermissionsState; use crate::context::world_state::PluginsInstructionsState; use crate::context::world_state::RealtimeState; use crate::context::world_state::WorldState; @@ -40,6 +42,31 @@ impl Session { .as_deref(), )); world_state.add_section(AgentsMdState::new(step_context.loaded_agents_md.as_deref())); + if turn_context.config.include_permissions_instructions { + let permission_profile = turn_context.permission_profile(); + let model_messages = turn_context.model_info.model_messages.as_ref(); + let exec_policy = self.services.exec_policy.current(); + world_state.add_section(PermissionsState::new( + &permission_profile, + turn_context.approval_policy.value(), + ApprovalPromptContext::new( + turn_context.config.approvals_reviewer, + model_messages.and_then(|messages| messages.approvals.as_ref()), + model_messages.and_then(|messages| messages.permissions.as_ref()), + ), + exec_policy.as_ref(), + #[allow(deprecated)] + &turn_context.cwd, + turn_context + .config + .features + .enabled(Feature::ExecPermissionApprovals), + turn_context + .config + .features + .enabled(Feature::RequestPermissionsTool), + )); + } if turn_context.config.include_collaboration_mode_instructions && let Some(collaboration_mode) = CollaborationModeState::from_collaboration_mode(&turn_context.collaboration_mode()) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2789cb6d11..13667b74ba 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1807,12 +1807,18 @@ async fn includes_user_instructions_message_in_request() { .contains("be nice") ); assert_message_role(&request_body["input"][0], "developer"); - let permissions_text = request_body["input"][0]["content"][0]["text"] - .as_str() - .expect("invalid permissions message content"); + let developer_texts = request_body["input"] + .as_array() + .expect("input array") + .iter() + .filter(|item| item.get("role").and_then(|role| role.as_str()) == Some("developer")) + .flat_map(message_input_texts) + .collect::>(); assert!( - permissions_text.contains("`sandbox_mode`"), - "expected permissions message to mention sandbox_mode, got {permissions_text:?}" + developer_texts + .iter() + .any(|text| text.contains("`sandbox_mode`")), + "expected permissions message to mention sandbox_mode, got {developer_texts:?}" ); assert_message_role(&request_body["input"][1], "user"); @@ -3095,10 +3101,6 @@ async fn includes_developer_instructions_message_in_request() { let request = resp_mock.single_request(); let request_body = request.body_json(); - let permissions_text = request_body["input"][0]["content"][0]["text"] - .as_str() - .expect("invalid permissions message content"); - assert!( !request_body["instructions"] .as_str() @@ -3106,23 +3108,22 @@ async fn includes_developer_instructions_message_in_request() { .contains("be nice") ); assert_message_role(&request_body["input"][0], "developer"); - assert!( - permissions_text.contains("`sandbox_mode`"), - "expected permissions message to mention sandbox_mode, got {permissions_text:?}" - ); - - let developer_messages: Vec<&serde_json::Value> = request_body["input"] + let developer_texts = request_body["input"] .as_array() .expect("input array") .iter() .filter(|item| item.get("role").and_then(|role| role.as_str()) == Some("developer")) - .collect(); + .flat_map(message_input_texts) + .collect::>(); assert!( - developer_messages + developer_texts .iter() - .any(|item| message_input_texts(item).contains(&"be useful")), - "expected developer instructions in a developer message, got {:?}", - request_body["input"] + .any(|text| text.contains("`sandbox_mode`")), + "expected permissions message to mention sandbox_mode, got {developer_texts:?}" + ); + assert!( + developer_texts.contains(&"be useful"), + "expected developer instructions in a developer message, got {developer_texts:?}" ); assert_message_role(&request_body["input"][1], "user"); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 78b8a90ba4..b4e3076b2a 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1233,18 +1233,20 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { return None; } - let text = value + let texts = value .get("content") .and_then(|content| content.as_array()) - .and_then(|content| content.first()) - .and_then(|item| item.get("text")) - .and_then(|text| text.as_str()); + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(|text| text.as_str())); // Ignore cached prefix messages (project docs + permissions) since they are not // relevant to compaction behavior and can change as bundled prompts evolve. let role = value.get("role").and_then(|role| role.as_str()); if role == Some("developer") - && text.is_some_and(|text| text.contains("`sandbox_mode`")) + && texts + .into_iter() + .any(|text| text.contains("`sandbox_mode`")) { return None; } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 90d48d77a4..ee5531f4b5 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -660,16 +660,10 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul let permissions_texts: Vec<&str> = input .iter() - .filter_map(|msg| { - let role = msg["role"].as_str()?; - if role != "developer" { - return None; - } - msg["content"] - .as_array() - .and_then(|content| content.first()) - .and_then(|item| item["text"].as_str()) - }) + .filter(|msg| msg["role"].as_str() == Some("developer")) + .filter_map(|msg| msg["content"].as_array()) + .flatten() + .filter_map(|item| item["text"].as_str()) .collect(); assert!( permissions_texts.iter().any(|text| { @@ -798,10 +792,6 @@ 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_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_settings_update_msg["role"].as_str(), Some("developer") @@ -812,7 +802,16 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }), "expected model switch section after model override: {expected_settings_update_msg:?}" ); - let expected_env_msg_2 = body2["input"][body1_input.len() + 1].clone(); + let expected_permissions_update_msg = body2["input"][body1_input.len() + 1].clone(); + assert_ne!( + expected_permissions_update_msg, expected_permissions_msg, + "expected updated permissions message after per-turn override" + ); + assert_eq!( + expected_permissions_update_msg["role"].as_str(), + Some("developer") + ); + let expected_env_msg_2 = body2["input"][body1_input.len() + 2].clone(); assert_eq!(expected_env_msg_2["role"].as_str(), Some("user")); let env_text = expected_env_msg_2["content"][0]["text"] .as_str() @@ -821,6 +820,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res assert_default_env_context(env_text, &expected_cwd); let mut expected_body2 = body1_input.to_vec(); expected_body2.push(expected_settings_update_msg); + expected_body2.push(expected_permissions_update_msg); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_user_message_2); assert_eq_without_metadata( @@ -1098,10 +1098,6 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let body1_input = body1["input"].as_array().expect("input array"); 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_settings_update_msg["role"].as_str(), Some("developer") @@ -1112,7 +1108,16 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu }), "expected model switch section after model override: {expected_settings_update_msg:?}" ); - let expected_env_update_msg = body2["input"][body1_input.len() + 1].clone(); + let expected_permissions_update_msg = body2["input"][body1_input.len() + 1].clone(); + assert_ne!( + expected_permissions_update_msg, expected_permissions_msg, + "expected updated permissions message after policy change" + ); + assert_eq!( + expected_permissions_update_msg["role"].as_str(), + Some("developer") + ); + let expected_env_update_msg = body2["input"][body1_input.len() + 2].clone(); assert_eq!(expected_env_update_msg["role"].as_str(), Some("user")); let expected_env_update_text = expected_env_update_msg["content"][0]["text"] .as_str() @@ -1130,6 +1135,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu expected_contextual_user_msg_1, expected_user_message_1, expected_settings_update_msg, + expected_permissions_update_msg, expected_env_update_msg, expected_user_message_2, ]); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_restates_realtime_start_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_restates_realtime_start_shapes.snap index 400e6d502b..196206a674 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_restates_realtime_start_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_restates_realtime_start_shapes.snap @@ -6,8 +6,8 @@ Scenario: Remote manual /compact while realtime remains active: the next regular ## Remote Compaction Request 00:message/developer[2]: - [01] - [02] \nRealtime conversation started.\n\nYou a... + [01] \nRealtime conversation started.\n\nYou a... + [02] 01:message/user:> 02:message/user:USER_ONE 03:message/assistant:REMOTE_FIRST_REPLY @@ -15,7 +15,7 @@ Scenario: Remote manual /compact while realtime remains active: the next regular ## Remote Post-Compaction History Layout 00:compaction:encrypted=true 01:message/developer[2]: - [01] - [02] \nRealtime conversation started.\n\nYou a... + [01] \nRealtime conversation started.\n\nYou a... + [02] 02:message/user:> 03:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_does_not_restate_realtime_end_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_does_not_restate_realtime_end_shapes.snap index 1e5021a58c..59e68c368a 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_does_not_restate_realtime_end_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_does_not_restate_realtime_end_shapes.snap @@ -6,8 +6,8 @@ Scenario: Remote mid-turn continuation compaction after realtime was closed befo ## Second Turn Initial Request 00:message/developer[2]: - [01] - [02] \nRealtime conversation started.\n\nYou a... + [01] \nRealtime conversation started.\n\nYou a... + [02] 01:message/user:> 02:message/user:SETUP_USER 03:message/assistant:REMOTE_SETUP_REPLY @@ -16,8 +16,8 @@ Scenario: Remote mid-turn continuation compaction after realtime was closed befo ## Remote Compaction Request 00:message/developer[2]: - [01] - [02] \nRealtime conversation started.\n\nYou a... + [01] \nRealtime conversation started.\n\nYou a... + [02] 01:message/user:> 02:message/user:SETUP_USER 03:message/assistant:REMOTE_SETUP_REPLY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_restates_realtime_start_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_restates_realtime_start_shapes.snap index 6de8837f1d..87abe26618 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_restates_realtime_start_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_restates_realtime_start_shapes.snap @@ -6,8 +6,8 @@ Scenario: Remote pre-turn auto-compaction while realtime remains active: compact ## Remote Compaction Request 00:message/developer[2]: - [01] - [02] \nRealtime conversation started.\n\nYou a... + [01] \nRealtime conversation started.\n\nYou a... + [02] 01:message/user:> 02:message/user:USER_ONE 03:message/assistant:REMOTE_FIRST_REPLY @@ -15,7 +15,7 @@ Scenario: Remote pre-turn auto-compaction while realtime remains active: compact ## Remote Post-Compaction History Layout 00:compaction:encrypted=true 01:message/developer[2]: - [01] - [02] \nRealtime conversation started.\n\nYou a... + [01] \nRealtime conversation started.\n\nYou a... + [02] 02:message/user:> 03:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_refreshes_agents.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_refreshes_agents.snap index 5285d6ea52..e8bd93595b 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_refreshes_agents.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_refreshes_agents.snap @@ -6,8 +6,8 @@ Scenario: Second turn changes cwd to a directory with different AGENTS.md and re ## First Request (agents_one) 00:message/developer[2]: - [01] - [02] + [01] + [02] 01:message/user[2]: [01] [02] > @@ -15,8 +15,8 @@ Scenario: Second turn changes cwd to a directory with different AGENTS.md and re ## Second Request (agents_two cwd) 00:message/developer[2]: - [01] - [02] + [01] + [02] 01:message/user[2]: [01] [02] > 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 93f1c504b1..7b91b52cdf 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 @@ -6,15 +6,15 @@ Scenario: First post-resume turn where pre-turn override sets model to rollout m ## Last Request Before Resume 00:message/developer[2]: - [01] - [02] + [01] + [02] 01:message/user:> 02:message/user:seed resume history ## First Request After Resume + Override 00:message/developer[2]: - [01] - [02] + [01] + [02] 01:message/user:> 02:message/user:seed resume history 03:message/assistant:recorded before resume 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 42d1cd1a9f..425f0596bf 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 @@ -6,20 +6,19 @@ Scenario: First post-resume turn where resumed config model differs from rollout ## Last Request Before Resume 00:message/developer[2]: - [01] - [02] + [01] + [02] 01:message/user:> 02:message/user:seed resume history ## First Request After Resume 00:message/developer[2]: - [01] - [02] + [01] + [02] 01:message/user:> 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 +04:message/developer:\nThe user was previously using a different model. Please continue the conversatio... +05:message/developer: +06:message/user: +07: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 8e66e3314c..a807a4cd0d 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,20 +6,19 @@ Scenario: Second turn changes cwd, approval policy, and personality while keepin ## First Request (Baseline) 00:message/developer[2]: - [01] - [02] + [01] + [02] 01:message/user:> 02:message/user:first turn ## Second Request (Turn Overrides) 00:message/developer[2]: - [01] - [02] + [01] + [02] 01:message/user:> 02:message/user:first turn 03:message/assistant:turn one complete -04:message/developer[2]: - [01] - [02] The user has requested a new communication style. Future messages should adhe... -05:message/user: -06:message/user:second turn with context updates +04:message/developer: The user has requested a new communication style. Future messages should adhe... +05:message/developer: +06:message/user: +07:message/user:second turn with context updates diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index 49687d347b..16e7ddac77 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -1,15 +1,14 @@ --- source: core/tests/suite/token_budget.rs -assertion_line: 545 expression: snapshot --- Scenario: New context window tool installs fresh full context before the next follow-up request. ## Final Follow-Up Request 00:message/developer[3]: - [01] - [02] - [03] \nThread id: \nFirst context window id: \nCurrent context window id: \nPrevious context window id: \n + [01] + [02] \nThread id: \nFirst context window id: \nCurrent context window id: \nPrevious context window id: \n + [03] 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated