Compare commits

...

6 Commits

Author SHA1 Message Date
Charles Cunningham
f561f10529 todo comment 2026-02-17 22:31:17 -08:00
Charles Cunningham
b5e6376dcf Add TODO for AGENTS refresh on cwd change 2026-02-17 22:31:16 -08:00
Charles Cunningham
2b225dcade Simplify settings update diffing logic 2026-02-17 22:31:16 -08:00
Charles Cunningham
8a4e7dab76 Rebase resume model switch on hydrated history 2026-02-17 22:31:16 -08:00
Charles Cunningham
08bfb1e7f2 Preserve personality updates with resumed-model hydration 2026-02-17 22:31:16 -08:00
Charles Cunningham
418321e388 Refactor settings updates to diff TurnContextItem 2026-02-17 22:31:16 -08:00
8 changed files with 442 additions and 163 deletions

View File

@@ -561,11 +561,15 @@ fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std
item: RolloutItem::TurnContext(TurnContextItem {
turn_id: None,
cwd: PathBuf::from("/"),
shell: "zsh".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permissions_instructions: String::new(),
network: None,
model: model.to_string(),
model_instructions: String::new(),
personality: None,
personality_spec: String::new(),
collaboration_mode: None,
effort: None,
summary: ReasoningSummary::Auto,

View File

@@ -540,6 +540,10 @@ pub(crate) struct TurnContext {
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) shell_name: String,
pub(crate) permissions_instructions: String,
pub(crate) model_instructions: String,
pub(crate) personality_spec: String,
/// The session's current working directory. All relative paths provided by
/// the model as well as sandbox policies are resolved against this path
/// instead of `std::env::current_dir()`.
@@ -610,6 +614,19 @@ impl TurnContext {
web_search_mode: self.tools_config.web_search_mode,
})
.with_agent_roles(config.agent_roles.clone());
let personality_spec = if features.enabled(Feature::Personality) {
self.personality
.and_then(|personality| {
crate::context_manager::updates::personality_message_for(
&model_info,
personality,
)
})
.unwrap_or_default()
} else {
String::new()
};
let model_instructions = model_info.get_model_instructions(self.personality);
Self {
sub_id: self.sub_id.clone(),
@@ -624,6 +641,10 @@ impl TurnContext {
reasoning_effort,
reasoning_summary: self.reasoning_summary,
session_source: self.session_source.clone(),
shell_name: self.shell_name.clone(),
permissions_instructions: self.permissions_instructions.clone(),
model_instructions,
personality_spec,
cwd: self.cwd.clone(),
developer_instructions: self.developer_instructions.clone(),
compact_prompt: self.compact_prompt.clone(),
@@ -667,11 +688,15 @@ impl TurnContext {
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
cwd: self.cwd.clone(),
shell: self.shell_name.clone(),
approval_policy: self.approval_policy,
sandbox_policy: self.sandbox_policy.clone(),
permissions_instructions: self.permissions_instructions.clone(),
network: self.turn_context_network_item(),
model: self.model_info.slug.clone(),
model_instructions: self.model_instructions.clone(),
personality: self.personality,
personality_spec: self.personality_spec.clone(),
collaboration_mode: Some(collaboration_mode),
effort: self.reasoning_effort,
summary: self.reasoning_summary,
@@ -924,6 +949,10 @@ impl Session {
per_turn_config: Config,
model_info: ModelInfo,
network: Option<NetworkProxy>,
shell_name: String,
permissions_instructions: String,
model_instructions: String,
personality_spec: String,
sub_id: String,
js_repl: Arc<JsReplHandle>,
) -> TurnContext {
@@ -966,6 +995,10 @@ impl Session {
reasoning_effort,
reasoning_summary,
session_source,
shell_name,
permissions_instructions,
model_instructions,
personality_spec,
cwd,
developer_instructions: session_configuration.developer_instructions.clone(),
compact_prompt: session_configuration.compact_prompt.clone(),
@@ -1881,6 +1914,30 @@ impl Session {
&per_turn_config,
)
.await;
let shell_name = self.user_shell().name().to_string();
let exec_policy = self.services.exec_policy.current();
let permissions_instructions = DeveloperInstructions::from_policy(
session_configuration.sandbox_policy.get(),
session_configuration.approval_policy.value(),
exec_policy.as_ref(),
&session_configuration.cwd,
)
.into_text();
let model_instructions =
model_info.get_model_instructions(session_configuration.personality);
let personality_spec = if per_turn_config.features.enabled(Feature::Personality) {
session_configuration
.personality
.and_then(|personality| {
crate::context_manager::updates::personality_message_for(
&model_info,
personality,
)
})
.unwrap_or_default()
} else {
String::new()
};
let mut turn_context: TurnContext = Self::make_turn_context(
Some(Arc::clone(&self.services.auth_manager)),
&self.services.otel_manager,
@@ -1892,6 +1949,10 @@ impl Session {
.network_proxy
.as_ref()
.map(StartedNetworkProxy::proxy),
shell_name,
permissions_instructions,
model_instructions,
personality_spec,
sub_id,
Arc::clone(&self.js_repl),
);
@@ -2007,23 +2068,12 @@ impl Session {
}
fn build_settings_update_items(
&self,
previous_context: Option<&Arc<TurnContext>>,
resumed_model: Option<&str>,
current_context: &TurnContext,
previous_context: Option<&TurnContextItem>,
current_context: &TurnContextItem,
) -> Vec<ResponseItem> {
// TODO: Make context updates a pure diff of persisted previous/current TurnContextItem
// state so replay/backtracking is deterministic. Runtime inputs that affect model-visible
// context (shell, exec policy, feature gates, resumed model bridge) should be persisted
// state or explicit non-state replay events.
let shell = self.user_shell();
let exec_policy = self.services.exec_policy.current();
crate::context_manager::updates::build_settings_update_items(
previous_context.map(Arc::as_ref),
resumed_model,
previous_context,
current_context,
shell.as_ref(),
exec_policy.as_ref(),
self.features.enabled(Feature::Personality),
)
}
@@ -3322,6 +3372,8 @@ mod handlers {
use codex_protocol::config_types::Settings;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::mcp::RequestId as ProtocolRequestId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
@@ -3422,12 +3474,48 @@ mod handlers {
// Attempt to inject input into current task.
if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await {
sess.seed_initial_context_if_needed(&current_context).await;
let previous_model = sess.previous_model().await;
let update_items = sess.build_settings_update_items(
previous_context.as_ref(),
previous_model.as_deref(),
&current_context,
);
let previous_context_item = previous_context
.as_ref()
.map(|context| context.to_turn_context_item(context.collaboration_mode.clone()));
let current_context_item =
current_context.to_turn_context_item(current_context.collaboration_mode.clone());
let mut update_items = sess
.build_settings_update_items(previous_context_item.as_ref(), &current_context_item);
if let Some(previous_model) = sess.previous_model().await
&& let Some(previous_context_item) = previous_context_item.as_ref()
&& previous_model != previous_context_item.model
{
// Rebase model-switch diffing on resume/fork model hydration so model-switch
// updates reflect rollout history while other diffs (for example personality) still
// use the real previous turn context.
update_items.retain(|item| !Session::is_model_switch_developer_message(item));
let model_switch_insert_index = update_items
.iter()
.position(|item| {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
role == "developer"
&& content.iter().any(|content_item| {
matches!(
content_item,
ContentItem::InputText { text } if text.starts_with("<personality_spec>")
)
})
})
.unwrap_or(update_items.len());
let mut previous_context_item_for_model_switch = previous_context_item.clone();
previous_context_item_for_model_switch.model = previous_model;
if let Some(model_switch_item) =
crate::context_manager::updates::build_model_instructions_update_item(
Some(&previous_context_item_for_model_switch),
&current_context_item,
)
{
update_items.insert(model_switch_insert_index, model_switch_item);
}
}
if !update_items.is_empty() {
sess.record_conversation_items(&current_context, &update_items)
.await;
@@ -4151,6 +4239,22 @@ async fn spawn_review_thread(
reasoning_effort,
reasoning_summary,
session_source,
shell_name: parent_turn_context.shell_name.clone(),
permissions_instructions: parent_turn_context.permissions_instructions.clone(),
model_instructions: model_info.get_model_instructions(parent_turn_context.personality),
personality_spec: if review_features.enabled(Feature::Personality) {
parent_turn_context
.personality
.and_then(|personality| {
crate::context_manager::updates::personality_message_for(
&model_info,
personality,
)
})
.unwrap_or_default()
} else {
String::new()
},
tools_config,
features: parent_turn_context.features.clone(),
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
@@ -6400,11 +6504,15 @@ mod tests {
let rollout_items = vec![RolloutItem::TurnContext(TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
cwd: turn_context.cwd.clone(),
shell: turn_context.shell_name.clone(),
approval_policy: turn_context.approval_policy,
sandbox_policy: turn_context.sandbox_policy.clone(),
permissions_instructions: turn_context.permissions_instructions.clone(),
network: None,
model: previous_model.to_string(),
model_instructions: turn_context.model_instructions.clone(),
personality: turn_context.personality,
personality_spec: turn_context.personality_spec.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
@@ -6619,11 +6727,15 @@ mod tests {
let rollout_items = vec![RolloutItem::TurnContext(TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
cwd: turn_context.cwd.clone(),
shell: turn_context.shell_name.clone(),
approval_policy: turn_context.approval_policy,
sandbox_policy: turn_context.sandbox_policy.clone(),
permissions_instructions: turn_context.permissions_instructions.clone(),
network: None,
model: previous_model.to_string(),
model_instructions: turn_context.model_instructions.clone(),
personality: turn_context.personality,
personality_spec: turn_context.personality_spec.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
@@ -7415,6 +7527,8 @@ mod tests {
config.js_repl_node_path.clone(),
config.codex_home.clone(),
));
let model_instructions =
model_info.get_model_instructions(session_configuration.personality);
let turn_context = Session::make_turn_context(
Some(Arc::clone(&auth_manager)),
@@ -7424,6 +7538,10 @@ mod tests {
per_turn_config,
model_info,
None,
"zsh".to_string(),
String::new(),
model_instructions,
String::new(),
"turn_id".to_string(),
Arc::clone(&js_repl),
);
@@ -7564,6 +7682,8 @@ mod tests {
config.js_repl_node_path.clone(),
config.codex_home.clone(),
));
let model_instructions =
model_info.get_model_instructions(session_configuration.personality);
let turn_context = Arc::new(Session::make_turn_context(
Some(Arc::clone(&auth_manager)),
@@ -7573,6 +7693,10 @@ mod tests {
per_turn_config,
model_info,
None,
"zsh".to_string(),
String::new(),
model_instructions,
String::new(),
"turn_id".to_string(),
Arc::clone(&js_repl),
));
@@ -7728,8 +7852,12 @@ mod tests {
.expect("rebuild config layer stack with network requirements");
current_context.config = Arc::new(config);
let update_items =
session.build_settings_update_items(Some(&previous_context), None, &current_context);
let previous_context_item =
previous_context.to_turn_context_item(previous_context.collaboration_mode.clone());
let current_context_item =
current_context.to_turn_context_item(current_context.collaboration_mode.clone());
let update_items = session
.build_settings_update_items(Some(&previous_context_item), &current_context_item);
let environment_update = update_items
.iter()

View File

@@ -1,84 +1,95 @@
use crate::codex::TurnContext;
use crate::environment_context::EnvironmentContext;
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;
use codex_protocol::protocol::TurnContextItem;
fn build_environment_update_item(
previous: Option<&TurnContext>,
next: &TurnContext,
shell: &Shell,
previous: Option<&TurnContextItem>,
next: &TurnContextItem,
) -> Option<ResponseItem> {
let prev = previous?;
let prev_context = EnvironmentContext::from_turn_context(prev, shell);
let next_context = EnvironmentContext::from_turn_context(next, shell);
if prev_context.equals_except_shell(&next_context) {
if prev.cwd == next.cwd && prev.network == next.network {
return None;
}
Some(ResponseItem::from(EnvironmentContext::diff(
prev, next, shell,
)))
let cwd = (prev.cwd != next.cwd).then_some(&next.cwd);
let network = next.network.as_ref();
let shell = if next.shell.is_empty() {
"unknown"
} else {
next.shell.as_str()
};
let mut lines = vec!["<environment_context>".to_string()];
if let Some(cwd) = cwd {
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
}
lines.push(format!(" <shell>{shell}</shell>"));
if let Some(network) = network {
lines.push(" <network enabled=\"true\">".to_string());
for allowed in &network.allowed_domains {
lines.push(format!(" <allowed>{allowed}</allowed>"));
}
for denied in &network.denied_domains {
lines.push(format!(" <denied>{denied}</denied>"));
}
lines.push(" </network>".to_string());
}
lines.push("</environment_context>".to_string());
Some(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: lines.join("\n"),
}],
end_turn: None,
phase: None,
})
}
fn build_permissions_update_item(
previous: Option<&TurnContext>,
next: &TurnContext,
exec_policy: &Policy,
previous: Option<&TurnContextItem>,
next: &TurnContextItem,
) -> Option<ResponseItem> {
let prev = previous?;
if prev.sandbox_policy == next.sandbox_policy && prev.approval_policy == next.approval_policy {
return None;
}
if next.permissions_instructions.is_empty() {
return None;
}
Some(
DeveloperInstructions::from_policy(
&next.sandbox_policy,
next.approval_policy,
exec_policy,
&next.cwd,
)
.into(),
)
Some(DeveloperInstructions::new(next.permissions_instructions.clone()).into())
}
fn build_collaboration_mode_update_item(
previous: Option<&TurnContext>,
next: &TurnContext,
previous: Option<&TurnContextItem>,
next: &TurnContextItem,
) -> Option<ResponseItem> {
let prev = previous?;
if prev.collaboration_mode != 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())
DeveloperInstructions::from_collaboration_mode(next.collaboration_mode.as_ref()?)
.map(Into::into)
} else {
None
}
}
fn build_personality_update_item(
previous: Option<&TurnContext>,
next: &TurnContext,
personality_feature_enabled: bool,
previous: Option<&TurnContextItem>,
next: &TurnContextItem,
) -> Option<ResponseItem> {
if !personality_feature_enabled {
return None;
}
let previous = previous?;
if next.model_info.slug != previous.model_info.slug {
if next.model != previous.model {
return None;
}
if let Some(personality) = next.personality
&& next.personality != previous.personality
{
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())
if next.personality != previous.personality && !next.personality_spec.is_empty() {
Some(DeveloperInstructions::personality_spec_message(next.personality_spec.clone()).into())
} else {
None
}
@@ -96,51 +107,40 @@ pub(crate) fn personality_message_for(
}
pub(crate) fn build_model_instructions_update_item(
previous: Option<&TurnContext>,
resumed_model: Option<&str>,
next: &TurnContext,
previous: Option<&TurnContextItem>,
next: &TurnContextItem,
) -> Option<ResponseItem> {
let previous_model =
resumed_model.or_else(|| previous.map(|prev| prev.model_info.slug.as_str()))?;
if previous_model == next.model_info.slug {
let previous = previous?;
if previous.model == next.model {
return None;
}
let model_instructions = next.model_info.get_model_instructions(next.personality);
if model_instructions.is_empty() {
if next.model_instructions.is_empty() {
return None;
}
Some(DeveloperInstructions::model_switch_message(model_instructions).into())
Some(DeveloperInstructions::model_switch_message(next.model_instructions.clone()).into())
}
pub(crate) fn build_settings_update_items(
previous: Option<&TurnContext>,
resumed_model: Option<&str>,
next: &TurnContext,
shell: &Shell,
exec_policy: &Policy,
personality_feature_enabled: bool,
previous: Option<&TurnContextItem>,
next: &TurnContextItem,
) -> Vec<ResponseItem> {
let mut update_items = Vec::new();
if let Some(env_item) = build_environment_update_item(previous, next, shell) {
if let Some(env_item) = build_environment_update_item(previous, next) {
update_items.push(env_item);
}
if let Some(permissions_item) = build_permissions_update_item(previous, next, exec_policy) {
if let Some(permissions_item) = build_permissions_update_item(previous, next) {
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(model_instructions_item) =
build_model_instructions_update_item(previous, resumed_model, next)
{
if let Some(model_instructions_item) = build_model_instructions_update_item(previous, next) {
update_items.push(model_instructions_item);
}
if let Some(personality_item) =
build_personality_update_item(previous, next, personality_feature_enabled)
{
if let Some(personality_item) = build_personality_update_item(previous, next) {
update_items.push(personality_item);
}

View File

@@ -31,35 +31,6 @@ impl EnvironmentContext {
}
}
/// Compares two environment contexts, ignoring the shell. Useful when
/// comparing turn to turn, since the initial environment_context will
/// include the shell, and then it is not configurable from turn to turn.
pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
let EnvironmentContext {
cwd,
network,
// should compare all fields except shell
shell: _,
} = other;
self.cwd == *cwd && self.network == *network
}
pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self {
let before_network = Self::network_from_turn_context(before);
let after_network = Self::network_from_turn_context(after);
let cwd = if before.cwd != after.cwd {
Some(after.cwd.clone())
} else {
None
};
let network = if before_network != after_network {
after_network
} else {
before_network
};
EnvironmentContext::new(cwd, shell.clone(), network)
}
pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
Self::new(
Some(turn_context.cwd.clone()),
@@ -237,51 +208,4 @@ mod tests {
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn equals_except_shell_compares_cwd() {
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None);
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None);
assert!(context1.equals_except_shell(&context2));
}
#[test]
fn equals_except_shell_ignores_sandbox_policy() {
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None);
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None);
assert!(context1.equals_except_shell(&context2));
}
#[test]
fn equals_except_shell_compares_cwd_differences() {
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell(), None);
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell(), None);
assert!(!context1.equals_except_shell(&context2));
}
#[test]
fn equals_except_shell_ignores_shell() {
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Shell {
shell_type: ShellType::Bash,
shell_path: "/bin/bash".into(),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
},
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Shell {
shell_type: ShellType::Zsh,
shell_path: "/bin/zsh".into(),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
},
None,
);
assert!(context1.equals_except_shell(&context2));
}
}

View File

@@ -1,6 +1,11 @@
use anyhow::Result;
use codex_core::config::types::Personality;
use codex_core::features::Feature;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
@@ -369,3 +374,196 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_model_hydration_does_not_suppress_personality_update() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.2".to_string());
});
let initial = builder.build(&server).await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-initial"),
ev_assistant_message("msg-1", "Completed first turn"),
ev_completed("resp-initial"),
]),
)
.await;
initial
.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "seed resumed history".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&initial.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let _ = initial_mock.single_request();
let resumed_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-resume"),
ev_assistant_message("msg-2", "Resumed turn"),
ev_completed("resp-resume"),
]),
)
.await;
let mut resume_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.2-codex".to_string());
config.features.enable(Feature::Personality);
config.personality = Some(Personality::Pragmatic);
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
resumed
.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "first resumed turn with personality change".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: resumed.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: resumed.session_configured.model.clone(),
effort: resumed.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: Some(Personality::Friendly),
})
.await?;
wait_for_event(&resumed.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let request = resumed_mock.single_request();
let developer_texts = request.message_input_texts("developer");
assert!(
developer_texts
.iter()
.any(|text| text.contains("<model_switch>")),
"expected model switch message on first post-resume turn"
);
assert!(
developer_texts
.iter()
.any(|text| text.contains("<personality_spec>")),
"expected personality update message when personality changes on first post-resume turn"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_override_matching_rollout_model_skips_model_switch_update() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.2".to_string());
});
let initial = builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-initial"),
ev_assistant_message("msg-1", "Completed first turn"),
ev_completed("resp-initial"),
]),
)
.await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "record initial rollout model".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let _ = initial_mock.single_request();
let resumed_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-resume"),
ev_assistant_message("msg-2", "Resumed turn"),
ev_completed("resp-resume"),
]),
)
.await;
let mut resume_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.2-codex".to_string());
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
resumed
.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
windows_sandbox_level: None,
model: Some("gpt-5.2".to_string()),
effort: None,
summary: None,
collaboration_mode: None,
personality: None,
})
.await?;
resumed
.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "first turn after override to rollout model".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&resumed.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let request = resumed_mock.single_request();
let developer_texts = request.message_input_texts("developer");
let model_switch_count = developer_texts
.iter()
.filter(|text| text.contains("<model_switch>"))
.count();
assert_eq!(
model_switch_count, 0,
"did not expect model switch update when override matches rollout model"
);
Ok(())
}

View File

@@ -22,11 +22,15 @@ fn resume_history(
let turn_ctx = TurnContextItem {
turn_id: None,
cwd: config.cwd.clone(),
shell: "zsh".to_string(),
approval_policy: config.permissions.approval_policy.value(),
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
permissions_instructions: String::new(),
network: None,
model: previous_model.to_string(),
model_instructions: String::new(),
personality: None,
personality_spec: String::new(),
collaboration_mode: None,
effort: config.model_reasoning_effort,
summary: config.model_reasoning_summary,

View File

@@ -1971,13 +1971,21 @@ pub struct TurnContextItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
pub cwd: PathBuf,
#[serde(default)]
pub shell: String,
pub approval_policy: AskForApproval,
pub sandbox_policy: SandboxPolicy,
#[serde(default)]
pub permissions_instructions: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub network: Option<TurnContextNetworkItem>,
pub model: String,
#[serde(default)]
pub model_instructions: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
#[serde(default)]
pub personality_spec: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collaboration_mode: Option<CollaborationMode>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -3041,6 +3049,10 @@ mod tests {
"summary": "auto",
}))?;
assert_eq!(item.shell, String::new());
assert_eq!(item.permissions_instructions, String::new());
assert_eq!(item.model_instructions, String::new());
assert_eq!(item.personality_spec, String::new());
assert_eq!(item.network, None);
Ok(())
}
@@ -3050,14 +3062,19 @@ mod tests {
let item = TurnContextItem {
turn_id: None,
cwd: PathBuf::from("/tmp"),
shell: "zsh".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permissions_instructions: "<permissions instructions>test</permissions instructions>"
.to_string(),
network: Some(TurnContextNetworkItem {
allowed_domains: vec!["api.example.com".to_string()],
denied_domains: vec!["blocked.example.com".to_string()],
}),
model: "gpt-5".to_string(),
model_instructions: "base model instructions".to_string(),
personality: None,
personality_spec: String::new(),
collaboration_mode: None,
effort: None,
summary: ReasoningSummaryConfig::Auto,

View File

@@ -1046,11 +1046,15 @@ mod tests {
TurnContextItem {
turn_id: None,
cwd,
shell: "zsh".to_string(),
approval_policy: config.permissions.approval_policy.value(),
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
permissions_instructions: String::new(),
network: None,
model,
model_instructions: String::new(),
personality: None,
personality_spec: String::new(),
collaboration_mode: None,
effort: config.model_reasoning_effort,
summary: config.model_reasoning_summary,