Make the update_plan tool opt-in (#41744)

## What changed

- Default `tools.update_plan.enabled` to `false`; users can explicitly enable it to expose `update_plan`.
- Remove bundled `update_plan` guidance from model, collaboration-mode, multi-agent, compaction, prewarm, and goal-continuation prompts when the tool is disabled.
- Preserve custom base instructions, model catalog instructions, collaboration policies, and user goal text even when they mention planning or `update_plan`.

## Testing

- Cover default and explicitly enabled tool registration, prompt consistency across request paths, and preservation of custom instructions.

GitOrigin-RevId: a53964e6e72f98e2557dd3090fdb7caccb956527
This commit is contained in:
rka-oai authored and copyberry committed 2026-08-31 00:53:30 +00:00
1 parent b7cd519c76
commit a9519cbcdd
31 files changed
+500 -71

No files matched your search

+16 -2
View File
@@ -165,8 +165,12 @@ async fn websocket_test_codex_shell_chain() -> Result<()> {
Ok(())
}
#[test_case::test_case(false; "update_plan disabled")]
#[test_case::test_case(true; "update_plan enabled")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> {
async fn websocket_first_turn_uses_startup_prewarm_and_create(
update_plan_enabled: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_websocket_server(vec![vec![
@@ -179,7 +183,9 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> {
]])
.await;
let mut builder = test_codex();
let mut builder = test_codex()
.with_model("gpt-5.2")
.with_config(move |config| config.update_plan_enabled = update_plan_enabled);
let test = builder.build_with_websocket_server(&server).await?;
test.submit_turn_with_policy("hello", test.config.legacy_sandbox_policy())
.await?;
@@ -192,6 +198,14 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> {
.expect("missing warmup request")
.body_json();
let turn = connection.get(1).expect("missing turn request").body_json();
assert_eq!(warmup["instructions"], turn["instructions"]);
assert_eq!(
warmup["instructions"]
.as_str()
.expect("warmup base instructions")
.contains("update_plan"),
update_plan_enabled
);
assert_eq!(warmup["type"].as_str(), Some("response.create"));
assert_eq!(warmup["generate"].as_bool(), Some(false));
let warmup_metadata: Value = serde_json::from_str(
+25
View File
@@ -1596,6 +1596,31 @@ text(output.output);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_does_not_expose_update_plan_by_default() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let (_test, second_mock) = run_code_mode_turn(
&server,
"inspect the available tools",
r#"
text(JSON.stringify({
callable: typeof tools.update_plan === "function",
listed: ALL_TOOLS.some(({ name }) => name === "update_plan"),
}));
"#,
)
.await?;
let (output, _) = custom_tool_output_body_and_success(&second_mock.single_request(), "call-1");
assert_eq!(
serde_json::from_str::<Value>(&output)?,
serde_json::json!({ "callable": false, "listed": false })
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_update_plan_nested_tool_result_is_empty_object() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -170,8 +170,8 @@ async fn catalog_collaboration_messages_track_mode_changes() -> Result<()> {
.await;
let model_slug = "catalog-collaboration-model";
let default_text = "catalog default instructions";
let plan_text = "catalog plan instructions";
let default_text = "## Plan tool\nPreserve the custom default policy.\n";
let plan_text = "## `update_plan`\nPreserve the custom Plan Mode policy.\n";
let model = model_with_collaboration_messages(model_slug, Some(default_text), Some(plan_text));
let mut builder = test_codex()
.with_model(model_slug)
+37 -18
View File
@@ -506,9 +506,17 @@ fn format_labeled_requests_snapshot(
)
}
#[test_case::test_case(false, false; "checklist disabled")]
#[test_case::test_case(true, false; "checklist enabled")]
#[test_case::test_case(false, true; "custom instructions with checklist disabled")]
#[test_case::test_case(true, true; "custom instructions with checklist enabled")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn summarize_context_three_requests_and_instructions() {
skip_if_no_network!();
async fn summarize_context_three_requests_and_instructions(
enable_plan: bool,
custom_instructions: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
const CUSTOM_INSTRUCTIONS: &str = "## Plan tool\nNever deploy without explicit approval.\n";
// Set up a mock server that we can inspect after the run.
let server = start_mock_server().await;
@@ -535,11 +543,16 @@ async fn summarize_context_three_requests_and_instructions() {
// Build config pointing to the mock server and spawn Codex.
let model_provider = non_openai_model_provider(&server);
let mut builder = test_codex().with_config(move |config| {
config.model = Some("gpt-5.2".to_string());
config.update_plan_enabled = enable_plan;
if custom_instructions {
config.base_instructions = Some(CUSTOM_INSTRUCTIONS.to_string());
}
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(200_000);
});
let test = builder.build(&server).await.unwrap();
let test = builder.build(&server).await?;
let codex = test.codex.clone();
let rollout_path = test.session_configured.rollout_path.expect("rollout path");
@@ -555,12 +568,11 @@ async fn summarize_context_three_requests_and_instructions() {
text_elements: Vec::new(),
},
]))
.await
.unwrap();
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// 2) Summarize – second hit should include the summarization prompt.
codex.submit(Op::Compact).await.unwrap();
codex.submit(Op::Compact).await?;
let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await;
let EventMsg::Warning(WarningEvent { message }) = warning_event else {
panic!("expected warning event after compact");
@@ -574,45 +586,51 @@ async fn summarize_context_three_requests_and_instructions() {
text: THIRD_USER_MSG.into(),
text_elements: Vec::new(),
}]))
.await
.unwrap();
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// Inspect the three captured requests.
let requests = request_log.requests();
assert_eq!(requests.len(), 3, "expected exactly three requests");
let body1 = requests[0].body_json();
let body2 = requests[1].body_json();
let body3 = requests[2].body_json();
// Manual compact should keep the baseline developer instructions.
let instr1 = body1.get("instructions").and_then(|v| v.as_str()).unwrap();
let instr2 = body2.get("instructions").and_then(|v| v.as_str()).unwrap();
let instr1 = requests[0].instructions_text();
let instr2 = requests[1].instructions_text();
if custom_instructions {
assert_eq!(instr1, CUSTOM_INSTRUCTIONS);
} else {
assert_eq!(instr1.contains("update_plan"), enable_plan);
}
assert_eq!(
instr1, instr2,
"manual compact should keep the standard developer instructions"
);
assert_eq!(requests[2].instructions_text(), instr1);
// The summarization request should include the injected user input marker.
let body2_str = body2.to_string();
let input2 = body2.get("input").and_then(|v| v.as_array()).unwrap();
let input2 = body2["input"].as_array().expect("compaction input array");
let has_compact_prompt = body_contains_text(&body2_str, SUMMARIZATION_PROMPT);
assert!(
has_compact_prompt,
"compaction request should include the summarize trigger"
);
// The last item is the user message created from the injected input.
let last2 = input2.last().unwrap();
assert_eq!(last2.get("type").unwrap().as_str().unwrap(), "message");
assert_eq!(last2.get("role").unwrap().as_str().unwrap(), "user");
let text2 = last2["content"][0]["text"].as_str().unwrap();
let last2 = input2.last().expect("summarization prompt");
assert_eq!(last2["type"], "message");
assert_eq!(last2["role"], "user");
let text2 = last2["content"][0]["text"]
.as_str()
.expect("summarization prompt text");
assert_eq!(
text2, SUMMARIZATION_PROMPT,
"expected summarize trigger, got `{text2}`"
);
// Third request must contain the refreshed instructions, compacted user history, and new user message.
let input3 = body3.get("input").and_then(|v| v.as_array()).unwrap();
let input3 = body3["input"].as_array().expect("follow-up input array");
assert!(
input3.len() >= 3,
@@ -670,7 +688,7 @@ async fn summarize_context_three_requests_and_instructions() {
);
// Shut down Codex to flush rollout entries before inspecting the file.
codex.submit(Op::Shutdown).await.unwrap();
codex.submit(Op::Shutdown).await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
let replacement_history = replacement_history_from_rollout(&rollout_path)
@@ -750,6 +768,7 @@ async fn summarize_context_three_requests_and_instructions() {
saw_compacted_summary,
"expected a Compacted entry containing the summarizer output"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+1
View File
@@ -543,6 +543,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
let mut builder = test_codex()
.with_model("exp-codex-personality")
.with_config(|config| {
config.update_plan_enabled = true;
config
.features
.disable(Feature::Personality)
+48 -7
View File
@@ -6,6 +6,7 @@ use std::path::Path;
use codex_core::TurnInputRequest;
use codex_core::shell::default_user_shell;
use codex_features::Feature;
use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ReasoningSummary;
@@ -117,10 +118,10 @@ fn normalize_newlines(text: &str) -> String {
text.replace("\r\n", "\n")
}
#[test_case(None, true, false; "default with model instructions")]
#[test_case(None, false, false; "default with model instructions")]
#[test_case(Some(true), true, false; "enabled with model instructions")]
#[test_case(Some(false), false, false; "disabled with model instructions")]
#[test_case(None, true, true; "default with custom instructions")]
#[test_case(None, false, true; "default with custom instructions")]
#[test_case(Some(true), true, true; "enabled with custom instructions")]
#[test_case(Some(false), false, true; "disabled with custom instructions")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -189,14 +190,44 @@ async fn prompt_tools_are_consistent_across_requests(
let base_instructions = if custom_instructions {
CUSTOM_BASE_INSTRUCTIONS.to_string()
} else {
model_info.get_model_instructions(config.personality)
let original = model_info.get_model_instructions(config.personality);
if expected_update_plan_enabled {
original
} else {
let (before, planning) = original.split_once("## Planning\n").unwrap();
let (_, after) = planning.split_once("\n## ").unwrap();
let (after, _) = after.split_once("## `update_plan`\n").unwrap();
format!("{before}## {after}")
}
};
let mode_instructions = if custom_instructions {
"## Plan tool\nPreserve this custom collaboration policy.\n".to_string()
} else {
builtin_collaboration_mode_presets()
.into_iter()
.find(|preset| preset.mode == Some(ModeKind::Plan))
.and_then(|preset| preset.developer_instructions.flatten())
.expect("built-in Plan mode instructions")
};
codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}]))
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-5.2".to_string(),
reasoning_effort: None,
developer_instructions: Some(mode_instructions.clone()),
},
}),
..Default::default()
}),
)
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
@@ -229,6 +260,16 @@ async fn prompt_tools_are_consistent_across_requests(
assert_tool_names(&body1, &expected_tools_names);
for request in [&req1, &req2] {
let developer_text = request
.single_request()
.message_input_texts("developer")
.join("\n");
if custom_instructions || expected_update_plan_enabled {
assert!(developer_text.contains(&mode_instructions));
} else {
assert!(!developer_text.contains("update_plan"));
assert!(developer_text.contains("Plan Mode (Conversational)"));
}
if let Some(instructions) = &config.developer_instructions {
assert!(
request
+41 -1
View File
@@ -62,6 +62,43 @@ use wiremock::matchers::path;
const REMOTE_MODEL_SLUG: &str = "codex-test";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn disabled_update_plan_preserves_custom_catalog_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
const INSTRUCTIONS: &str = "## Plan tool\nNever deploy without explicit approval.\n";
let server = start_mock_server().await;
let response = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let mut catalog = bundled_models_response()?;
let model = catalog
.models
.iter_mut()
.find(|model| model.slug == "gpt-5.2")
.expect("bundled gpt-5.2 model");
let messages = model
.model_messages
.as_mut()
.expect("model prompt templates");
messages.instructions_template = Some(INSTRUCTIONS.to_string());
messages.instructions_variables = None;
let test = test_codex()
.with_model("gpt-5.2")
.with_config(move |config| {
config.update_plan_enabled = false;
config.model_catalog = Some(catalog);
})
.build_with_auto_env(&server)
.await?;
test.submit_turn("hello").await?;
let request = response.single_request().body_json();
assert_eq!(request["instructions"], INSTRUCTIONS);
assert!(!request["tools"].to_string().contains("update_plan"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unknown_model_sends_builtin_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -72,7 +109,9 @@ async fn unknown_model_sends_builtin_instructions() -> Result<()> {
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let mut builder = test_codex().with_model("future-custom-model");
let mut builder = test_codex()
.with_model("future-custom-model")
.with_config(|config| config.update_plan_enabled = true);
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("use fallback model metadata").await?;
@@ -919,6 +958,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> {
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
config.update_plan_enabled = true;
config.model = Some("gpt-5.2".to_string());
});
let TestCodex {