diff --git a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs index a237875d5b..eab39bba8f 100644 --- a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs +++ b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses; +use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::time::Duration; @@ -170,9 +171,20 @@ async fn recommended_plugins_after_external_login( .any(|text| text.contains("suggest a plugin")) }) .expect("turn request"); - let contextual_user_message = request.message_input_texts("user").join("\n"); - assert!(contextual_user_message.contains("")); - assert!(contextual_user_message.contains("- GitHub (github@openai-curated-remote)")); + let recommendations = request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect::>(); + assert_eq!( + recommendations, + vec![concat!( + "\n", + "Here is a list of plugins that are available but not installed.\n\n", + "- GitHub (github@openai-curated-remote)\n", + "", + )] + ); let body = request.body_json(); let tool_names = body .get("tools") diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index 56563aeaf6..9772ef0086 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -27,6 +27,7 @@ const CONTEXTUAL_USER_FRAGMENT_MATCHERS: &[fn(&str) -> bool] = &[ TurnAborted::matches_text, SubagentNotification::matches_text, InternalModelContextFragment::matches_text, + // compatibility for user-role recommendation messages in existing rollouts RecommendedPluginsInstructions::matches_text, LegacyUnifiedExecProcessLimitWarning::matches_text, LegacyApplyPatchExecCommandWarning::matches_text, diff --git a/codex-rs/core/src/context/recommended_plugins_instructions.rs b/codex-rs/core/src/context/recommended_plugins_instructions.rs index 829c56fb61..4425b08511 100644 --- a/codex-rs/core/src/context/recommended_plugins_instructions.rs +++ b/codex-rs/core/src/context/recommended_plugins_instructions.rs @@ -32,7 +32,7 @@ impl ContextualUserFragment for RecommendedPluginsInstructions { } fn role(&self) -> &'static str { - "user" + "developer" } fn markers(&self) -> (&'static str, &'static str) { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9c48261b7f..2965bac15d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -4226,12 +4226,6 @@ impl Session { } else { None }; - if let Some(recommended_plugins) = recommended_plugin_candidates - .as_deref() - .and_then(RecommendedPluginsInstructions::from_plugins) - { - contextual_user_sections.push(recommended_plugins.render_fragment()); - } let context_contributors = self.services.extensions.context_contributors().to_vec(); for contributor in &context_contributors { for fragment in contributor @@ -4360,6 +4354,13 @@ impl Session { } } + if let Some(recommended_plugins) = recommended_plugin_candidates + .as_deref() + .and_then(RecommendedPluginsInstructions::from_plugins) + { + developer_sections.push(recommended_plugins.render_fragment()); + } + let mut items = Vec::with_capacity(4); if let Some(developer_message) = crate::context_manager::updates::build_rendered_message(developer_sections) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 46c47091a1..911fb1e4a6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9986,7 +9986,10 @@ async fn build_initial_context_reuses_in_flight_recommendation_prewarm() { let (_, initial_context) = tokio::join!(prewarm, initial_context); assert_eq!( - user_input_texts(&initial_context), + developer_input_texts(&initial_context) + .into_iter() + .filter(|text| text.starts_with("")) + .collect::>(), vec![concat!( "\n", "Here is a list of plugins that are available but not installed.\n\n", diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index e7876381e7..33a094a03b 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -34,6 +34,8 @@ use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_utils_path_uri::PathUri; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -457,7 +459,7 @@ async fn mount_remote_calendar_installed_plugins(server: &wiremock::MockServer) #[test_case(Feature::RecommendedPlugins, Some(Feature::RemotePlugin); "remote plugins disabled")] #[test_case(Feature::RecommendedPlugins, Some(Feature::RecommendedPlugins); "recommendations disabled")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn startup_recommendations( +async fn startup_recommendations_use_developer_message( feature: Feature, disabled_feature: Option, ) -> Result<()> { @@ -546,16 +548,53 @@ async fn startup_recommendations( test.submit_turn("suggest a plugin").await?; + if feature == Feature::RecommendedPlugins && disabled_feature.is_none() { + insta::assert_snapshot!( + "startup_recommendations_developer_context", + context_snapshot::format_request_history_snapshot( + "Startup recommendations share the developer message with other context.", + &model_response.requests(), + &ContextSnapshotOptions::default().rewrite_known_segments(), + ) + ); + } + let request = model_response.single_request(); - let user_context = request.message_input_texts("user").join("\n"); + let developer_context = request.message_input_texts("developer").join("\n"); assert_eq!( - user_context.contains(""), + developer_context.contains(""), expect_recommendations, ); assert_eq!( - user_context.contains("- GitHub (github@openai-curated-remote)"), + developer_context.contains("- GitHub (github@openai-curated-remote)"), expect_recommendations, ); + if expect_recommendations { + let developer_messages = request.message_input_text_groups("developer"); + let recommendation_message = developer_messages + .iter() + .find(|message| { + message + .iter() + .any(|text| text.starts_with("")) + }) + .expect("developer message with recommendations"); + assert!( + recommendation_message + .iter() + .any(|text| text.starts_with("")), + "recommendations should share the developer message with other context" + ); + assert_eq!( + recommendation_message.last().map(String::as_str), + Some(concat!( + "\n", + "Here is a list of plugins that are available but not installed.\n\n", + "- GitHub (github@openai-curated-remote)\n", + "", + )) + ); + } let tools = tool_names(&request.body_json()); assert_eq!( tools @@ -622,7 +661,7 @@ async fn mcp_discovery_overlaps_endpoint_plugin_recommendations() -> Result<()> let request = response.single_request(); assert!( request - .message_input_texts("user") + .message_input_texts("developer") .join("\n") .contains("github@openai-curated-remote"), "the completed request should preserve endpoint recommendations" @@ -733,7 +772,7 @@ async fn unavailable_recommendations_preserve_legacy_workflow( let request = &requests[0]; assert!( !request - .message_input_texts("user") + .message_input_texts("developer") .join("\n") .contains("") ); @@ -1028,10 +1067,23 @@ async fn endpoint_mode_injects_candidates_hides_list_and_rejects_invented_ids() let requests = mock.requests(); assert_eq!(requests.len(), 2); - let contextual_user_message = requests[0].message_input_texts("user").join("\n"); - assert!(contextual_user_message.contains("")); - assert!(contextual_user_message.contains("github@openai-curated-remote")); - assert!(contextual_user_message.contains("google-calendar@openai-curated-remote")); + for request in &requests { + let recommendations = request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect::>(); + assert_eq!( + recommendations, + vec![concat!( + "\n", + "Here is a list of plugins that are available but not installed.\n\n", + "- GitHub (github@openai-curated-remote)\n", + "- Google Calendar (google-calendar@openai-curated-remote)\n", + "", + )] + ); + } let body = requests[0].body_json(); let tools = tool_names(&body); assert!( @@ -1435,7 +1487,7 @@ async fn endpoint_mode_with_no_eligible_candidates_exposes_no_suggestion_tools() let request = mock.single_request(); assert!( !request - .message_input_texts("user") + .message_input_texts("developer") .join("\n") .contains("") ); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__startup_recommendations_developer_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__startup_recommendations_developer_context.snap new file mode 100644 index 0000000000..572158ec39 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__startup_recommendations_developer_context.snap @@ -0,0 +1,20 @@ +--- +source: core/tests/suite/request_plugin_install.rs +expression: "context_snapshot::format_request_history_snapshot(\"Startup recommendations share the developer message with other context.\",\n&model_response.requests(),\n&ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: Startup recommendations share the developer message with other context. + +## Window 1 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + [02] + [03] + Here is a list of plugins that are available but not installed. + + - GitHub (github@openai-curated-remote) + +01:message/user: + +02:message/user: + suggest a plugin