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]