Refresh plugin recommendations when tooling state changes (#61387)

## Why

Cached recommendations can keep offering a plugin after installation. Refreshing the shared catalog during step preparation can also leave the recommendation prompt and install handler with different candidates.

## What changed

- Check `tooling_changed_at` before reusing recommendations, with a one-second reuse window and a 250 ms deadline that includes authentication. Refresh the catalog when the marker changes, preserving cached recommendations on failure and retrying on a later poll.
- Scope recommendation and featured-plugin caches by product, backend, and account identity, and share concurrent recommendation refreshes.
- Use the same recommendation snapshot for a step's prompt and install handler, while allowing later steps to observe updated recommendations.

## Testing

Add coverage for marker changes, missing markers, refresh failures, concurrent and cancelled requests, and the authentication deadline. Extend integration tests to verify consistent step candidates and stop offering an installed plugin while preserving the original recommendation block in conversation history.

GitOrigin-RevId: 44fa6723a623d9604f0c768118bc6889d2d3b693
This commit is contained in:
Alex Zamoshchin
2026-11-13 22:16:32 +00:00
committed by copyberry
parent 826b6e8199
commit 42f950cd11
7 changed files with 460 additions and 327 deletions
@@ -382,19 +382,38 @@ async fn resolve_install_elicitation(
Ok(())
}
async fn mount_remote_calendar_recommendation(server: &wiremock::MockServer) {
async fn mount_remote_calendar_recommendation(
server: &wiremock::MockServer,
installed: Arc<AtomicBool>,
) {
let installed_for_marker = Arc::clone(&installed);
Mock::given(method("GET"))
.and(path("/ps/plugins/tooling-state/turtle"))
.respond_with(move |_: &wiremock::Request| {
let marker = installed_for_marker
.load(Ordering::SeqCst)
.then_some("1700000000000");
ResponseTemplate::new(200).set_body_json(json!({ "tooling_changed_at": marker }))
})
.mount(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": REMOTE_CALENDAR_PLUGIN_ID,
"name": "calendar",
"display_name": "Calendar"
}]
})))
.expect(1)
.and(query_param("includeInstalled", "false"))
.respond_with(move |_: &wiremock::Request| {
let plugins = if installed.load(Ordering::SeqCst) {
json!([])
} else {
json!([{
"id": REMOTE_CALENDAR_PLUGIN_ID,
"name": "calendar",
"display_name": "Calendar"
}])
};
ResponseTemplate::new(200).set_body_json(json!({ "enabled": true, "plugins": plugins }))
})
.expect(2)
.mount(server)
.await;
Mock::given(method("GET"))
@@ -788,6 +807,18 @@ async fn mcp_discovery_overlaps_endpoint_plugin_recommendations() -> Result<()>
response.requests().is_empty(),
"sampling should wait for the complete MCP catalog"
);
// Change the shared catalog after the step's recommendation request, while MCP is blocked.
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested/codex"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"enabled": true, "plugins": []
})))
.with_priority(1)
.mount(&server)
.await;
test.thread_manager
.plugins_manager()
.clear_recommended_plugins_cache();
test.fs()
.write_file(
&barrier,
@@ -809,6 +840,11 @@ async fn mcp_discovery_overlaps_endpoint_plugin_recommendations() -> Result<()>
.contains("github@openai-curated-remote"),
"the completed request should preserve endpoint recommendations"
);
assert!(
tool_names(&request.body_json())
.iter()
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME)
);
assert!(
request
.tool_by_name("mcp__step_preparation", "echo")
@@ -1541,7 +1577,8 @@ async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTo
}
RefreshedAppsTools::Missing => AppsTestServer::mount_without_tools(&server).await?,
};
mount_remote_calendar_recommendation(&server).await;
let installed = Arc::new(AtomicBool::new(false));
mount_remote_calendar_recommendation(&server, Arc::clone(&installed)).await;
let initial_remote_installed_plugins = mount_empty_remote_installed_plugins(&server).await;
let install_call_id = "install-calendar";
@@ -1579,6 +1616,7 @@ async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTo
let elicitation = start_install_turn(&test, "use Calendar").await?;
mount_remote_calendar_installed_plugins(&server).await;
drop(initial_remote_installed_plugins);
installed.store(true, Ordering::SeqCst);
tools_available.store(
matches!(refreshed_tools, RefreshedAppsTools::Available),
Ordering::SeqCst,
@@ -1623,10 +1661,32 @@ async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTo
.codex
.refresh_runtime_config(current_config, test.config.clone())
.await;
// Let the one-second marker poll window expire before the next turn.
tokio::time::sleep(Duration::from_secs(1)).await;
test.submit_turn("check whether Calendar is still installed")
.await?;
let requests = mock.requests();
assert_eq!(requests.len(), 3);
let recommendation_blocks = |index: usize| {
requests[index]
.message_input_texts("developer")
.into_iter()
.filter(|text| text.starts_with("<recommended_plugins>"))
.collect::<Vec<_>>()
};
let initial = recommendation_blocks(0);
assert_eq!(initial.len(), 1);
assert_eq!(
recommendation_blocks(2),
initial,
"refreshing recommendations must preserve the original block"
);
assert!(
!tool_names(&requests[2].body_json())
.iter()
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
"the updated recommendations must stop offering to install Calendar"
);
assert_eq!(
requests[2]
.tool_by_name(CALENDAR_NAMESPACE, CALENDAR_CREATE_EVENT_TOOL)