Move plugin recommendations into developer context (#47349)

## What changed

Emit `<recommended_plugins>` with the `developer` role and append it to the shared developer message after other context. Retain recognition of legacy user-role recommendations in existing rollouts.

## Testing

Add a startup context snapshot and assertions that recommendations share the developer message with permissions instructions. Update core and app-server tests to check developer-role recommendations, including across successive model requests.

GitOrigin-RevId: 0326ff347e86ec38b948f93e39e90e6a88a8730c
This commit is contained in:
Alex Daley
2026-09-22 19:51:33 +00:00
committed by copyberry
parent 5121aeff13
commit 9124e23e2c
7 changed files with 111 additions and 22 deletions
@@ -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("<recommended_plugins>"));
assert!(contextual_user_message.contains("- GitHub (github@openai-curated-remote)"));
let recommendations = request
.message_input_texts("developer")
.into_iter()
.filter(|text| text.starts_with("<recommended_plugins>"))
.collect::<Vec<_>>();
assert_eq!(
recommendations,
vec![concat!(
"<recommended_plugins>\n",
"Here is a list of plugins that are available but not installed.\n\n",
"- GitHub (github@openai-curated-remote)\n",
"</recommended_plugins>",
)]
);
let body = request.body_json();
let tool_names = body
.get("tools")
@@ -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,
@@ -32,7 +32,7 @@ impl ContextualUserFragment for RecommendedPluginsInstructions {
}
fn role(&self) -> &'static str {
"user"
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
+7 -6
View File
@@ -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)
+4 -1
View File
@@ -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("<recommended_plugins>"))
.collect::<Vec<_>>(),
vec![concat!(
"<recommended_plugins>\n",
"Here is a list of plugins that are available but not installed.\n\n",
@@ -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<Feature>,
) -> 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("<recommended_plugins>"),
developer_context.contains("<recommended_plugins>"),
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("<recommended_plugins>"))
})
.expect("developer message with recommendations");
assert!(
recommendation_message
.iter()
.any(|text| text.starts_with("<permissions instructions>")),
"recommendations should share the developer message with other context"
);
assert_eq!(
recommendation_message.last().map(String::as_str),
Some(concat!(
"<recommended_plugins>\n",
"Here is a list of plugins that are available but not installed.\n\n",
"- GitHub (github@openai-curated-remote)\n",
"</recommended_plugins>",
))
);
}
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("<recommended_plugins>")
);
@@ -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("<recommended_plugins>"));
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("<recommended_plugins>"))
.collect::<Vec<_>>();
assert_eq!(
recommendations,
vec![concat!(
"<recommended_plugins>\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",
"</recommended_plugins>",
)]
);
}
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("<recommended_plugins>")
);
@@ -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] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
[03] <recommended_plugins>
Here is a list of plugins that are available but not installed.
- GitHub (github@openai-curated-remote)
</recommended_plugins>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
suggest a plugin