mirror of
https://github.com/openai/codex.git
synced 2026-03-03 05:03:20 +00:00
Compare commits
6 Commits
fix/notify
...
cc/turn-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f561f10529 | ||
|
|
b5e6376dcf | ||
|
|
2b225dcade | ||
|
|
8a4e7dab76 | ||
|
|
08bfb1e7f2 | ||
|
|
418321e388 |
@@ -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,
|
||||
|
||||
@@ -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(¤t_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(),
|
||||
¤t_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(), ¤t_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),
|
||||
¤t_context_item,
|
||||
)
|
||||
{
|
||||
update_items.insert(model_switch_insert_index, model_switch_item);
|
||||
}
|
||||
}
|
||||
if !update_items.is_empty() {
|
||||
sess.record_conversation_items(¤t_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, ¤t_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), ¤t_context_item);
|
||||
|
||||
let environment_update = update_items
|
||||
.iter()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user