Preload plugin recommendations during session startup (#41375)

## Why

Fetching plugin recommendations while building the first-turn context delays that
context when the recommendation catalog is not already cached.

## What changed

- Start fetching recommendations during session initialization, in parallel with
  MCP and plugin setup, and reuse the in-flight request when building context.
- Centralize the feature checks that enable plugin recommendations.
- Prevent an invalidated recommendation request from repopulating the cache after
  a newer request has completed.

## Testing

Added coverage for startup feature gating, reuse of the in-flight fetch, legacy
behavior when recommendations are unavailable, and cache invalidation races.

GitOrigin-RevId: 708774b9bfc472c9847973ae8b2774b66e7321e4
This commit is contained in:
Matthew Zeng authored and copyberry committed 2026-08-28 17:33:10 +00:00
1 parent 5ed294d49d
commit 8bcac28f93
7 files changed
+391 -14

No files matched your search

+4 -5
View File
@@ -3731,11 +3731,10 @@ impl Session {
.plugins_manager
.plugins_for_config(&turn_context.config.plugins_config_input())
.await;
let features = turn_context.config.features.get();
let recommended_plugin_candidates = if features.enabled(Feature::Apps)
&& features.enabled(Feature::Plugins)
&& (features.enabled(Feature::ToolSuggest)
|| features.enabled(Feature::RecommendedPlugins))
let recommended_plugin_candidates = if turn_context
.config
.features
.plugin_recommendations_enabled()
{
let auth = self.services.auth_manager.auth().await;
let plugins_config = turn_context.config.plugins_config_input();
+15
View File
@@ -928,6 +928,7 @@ impl Session {
let mut mcp_auth_changes = auth_manager.auth_change_receiver();
let auth_manager_clone = Arc::clone(&auth_manager);
let plugins_manager_for_prewarm = Arc::clone(&plugins_manager);
let config_for_mcp = Arc::clone(&config);
let mcp_manager_for_mcp = Arc::clone(&mcp_manager);
let mcp_thread_init_for_startup = &mcp_thread_init;
@@ -941,6 +942,20 @@ impl Session {
.unwrap_or_else(|| session_configuration.cwd().to_path_buf());
let auth_and_mcp_fut = async move {
let auth = auth_manager_clone.auth().await;
if config_for_mcp.features.plugin_recommendations_enabled() {
let plugins_config = config_for_mcp.plugins_config_input();
let auth_for_prewarm = auth.clone();
// Fetch the catalog while MCP and plugin/skill initialization continue.
// Context construction still handles filtering and prompt insertion.
tokio::spawn(async move {
plugins_manager_for_prewarm
.recommended_plugins_mode_for_config(
&plugins_config,
auth_for_prewarm.as_ref(),
)
.await;
});
}
let mcp_projection = mcp_manager_for_mcp
.runtime_config_for_step(
&config_for_mcp,
+80
View File
@@ -9726,6 +9726,86 @@ async fn record_context_updates_emits_realtime_end_when_session_stops_being_live
);
}
#[tokio::test]
async fn build_initial_context_reuses_in_flight_recommendation_prewarm() {
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
core_test_support::skip_if_no_network!();
let server = start_mock_server().await;
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested/codex"))
.and(query_param("scope", "GLOBAL"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"enabled": true,
"plugins": [{
"id": "plugin_github",
"name": "github",
"display_name": "GitHub"
}]
})))
.expect(1)
.mount(&server)
.await;
let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
/*dynamic_tools*/ Vec::new(),
|config| {
config.chatgpt_base_url = server.uri();
config
.features
.disable(Feature::ToolSuggest)
.expect("test config should allow feature update");
for enabled_feature in [
Feature::Apps,
Feature::Plugins,
Feature::RemotePlugin,
Feature::RecommendedPlugins,
] {
config
.features
.enable(enabled_feature)
.expect("test config should allow feature update");
}
},
)
.await;
let plugins_manager = &session.services.plugins_manager;
let plugins_config = turn_context.config.plugins_config_input();
let auth = session.services.auth_manager.auth().await;
// Cached plugin loading and auth let initial context reach the shared recommendation lookup
// without awaiting unrelated I/O.
plugins_manager.plugins_for_config(&plugins_config).await;
let prewarm =
plugins_manager.recommended_plugins_mode_for_config(&plugins_config, auth.as_ref());
tokio::pin!(prewarm);
assert!(futures::poll!(prewarm.as_mut()).is_pending());
// Keep the OnceCell initializer unpolled while first-thread context joins its in-flight fetch.
// This does not depend on how quickly the HTTP server returns its response.
let world_state = WorldState::default();
let initial_context =
session.build_initial_context_with_world_state(&turn_context, &world_state);
tokio::pin!(initial_context);
assert!(futures::poll!(initial_context.as_mut()).is_pending());
let (_, initial_context) = tokio::join!(prewarm, initial_context);
assert_eq!(
user_input_texts(&initial_context),
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>",
)]
);
server.verify().await;
}
#[tokio::test]
async fn build_initial_context_describes_active_realtime_state() {
let (session, mut turn_context) = make_session_and_context().await;