Skip to content

Commit a34da3b

Browse files
[codex] [3/4] Activate endpoint plugin recommendations (openai#27704)
Summary\n- Await endpoint recommendation selection while constructing each authenticated turn, removing the first-turn cache race.\n- Snapshot and filter endpoint candidates once per turn, then use that same set for the bounded contextual user fragment, tool exposure, and exact install validation.\n- Keep recommendation selection ephemeral: do not persist recommendation state in or gate resumed threads on prior context.\n- Hide the legacy list tool in endpoint mode and preserve legacy discovery unchanged when the endpoint is disabled or unavailable.\n- Keep remote plugin and connector app identities out of model-visible context and attach them only to Codex-owned elicitation metadata.\n\nStack\n- 3/4, based on openai#28400.\n- Endpoint client and cache: openai#28399.\n- Generalized suggestion presentation: openai#28400.\n- Install-schema follow-up: openai#28403.\n\nValidation\n- \n- \n- \n- \n- Full : 2,649 passed and 88 environment-dependent tests failed because this sandbox cannot write , nest Seatbelt, or locate auxiliary test binaries.
1 parent 587487d commit a34da3b

18 files changed

Lines changed: 846 additions & 124 deletions

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/app-server/tests/suite/v2/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ mod process_exec;
4444
mod rate_limit_reset_credits;
4545
mod rate_limits;
4646
mod realtime_conversation;
47+
mod recommended_plugins;
4748
mod remote_control;
4849
#[cfg(debug_assertions)]
4950
mod remote_thread_store;
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
use anyhow::Result;
2+
use app_test_support::ChatGptIdTokenClaims;
3+
use app_test_support::TestAppServer;
4+
use app_test_support::encode_id_token;
5+
use app_test_support::to_response;
6+
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
7+
use codex_app_server_protocol::JSONRPCResponse;
8+
use codex_app_server_protocol::LoginAccountResponse;
9+
use codex_app_server_protocol::RequestId;
10+
use codex_app_server_protocol::ThreadStartParams;
11+
use codex_app_server_protocol::ThreadStartResponse;
12+
use codex_app_server_protocol::TurnStartParams;
13+
use codex_app_server_protocol::UserInput;
14+
use core_test_support::apps_test_server::AppsTestServer;
15+
use core_test_support::responses;
16+
use serde_json::Value;
17+
use serde_json::json;
18+
use std::time::Duration;
19+
use tempfile::TempDir;
20+
use tokio::time::timeout;
21+
use wiremock::Mock;
22+
use wiremock::ResponseTemplate;
23+
use wiremock::matchers::method;
24+
use wiremock::matchers::path;
25+
use wiremock::matchers::query_param;
26+
27+
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(20);
28+
const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174010";
29+
30+
#[tokio::test]
31+
async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Result<()> {
32+
let server = responses::start_mock_server().await;
33+
let apps_server = AppsTestServer::mount(&server).await?;
34+
Mock::given(method("GET"))
35+
.and(path("/ps/plugins/suggested"))
36+
.and(query_param("scope", "GLOBAL"))
37+
.respond_with(
38+
ResponseTemplate::new(200)
39+
.set_delay(Duration::from_millis(250))
40+
.set_body_json(json!({
41+
"enabled": true,
42+
"plugins": [{
43+
"id": "plugin_github",
44+
"name": "github",
45+
"status": "ENABLED",
46+
"installation_policy": "AVAILABLE",
47+
"release": {"display_name": "GitHub"}
48+
}]
49+
})),
50+
)
51+
.expect(1)
52+
.mount(&server)
53+
.await;
54+
let response = responses::sse(vec![
55+
responses::ev_response_created("resp-1"),
56+
responses::ev_assistant_message("msg-1", "done"),
57+
responses::ev_completed("resp-1"),
58+
]);
59+
let responses_mock = responses::mount_sse_once(&server, response).await;
60+
61+
let codex_home = TempDir::new()?;
62+
write_mock_responses_config_toml_with_chatgpt_base_url(
63+
codex_home.path(),
64+
&server.uri(),
65+
&apps_server.chatgpt_base_url,
66+
)?;
67+
let config_path = codex_home.path().join("config.toml");
68+
let config = std::fs::read_to_string(&config_path)?;
69+
std::fs::write(
70+
config_path,
71+
format!(
72+
"{config}\n[features]\napps = true\nplugins = true\nremote_plugin = true\ntool_suggest = true\n"
73+
),
74+
)?;
75+
76+
let sqlite_home = codex_home.path().to_string_lossy();
77+
let mut app_server = TestAppServer::new_without_managed_config_with_env(
78+
codex_home.path(),
79+
&[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))],
80+
)
81+
.await?;
82+
timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??;
83+
84+
let access_token = encode_id_token(
85+
&ChatGptIdTokenClaims::new()
86+
.email("embedded@example.com")
87+
.plan_type("pro")
88+
.chatgpt_account_id(WORKSPACE_ID),
89+
)?;
90+
let login_id = app_server
91+
.send_chatgpt_auth_tokens_login_request(
92+
access_token,
93+
WORKSPACE_ID.to_string(),
94+
Some("pro".to_string()),
95+
)
96+
.await?;
97+
let login_response: JSONRPCResponse = timeout(
98+
DEFAULT_READ_TIMEOUT,
99+
app_server.read_stream_until_response_message(RequestId::Integer(login_id)),
100+
)
101+
.await??;
102+
assert_eq!(
103+
to_response::<LoginAccountResponse>(login_response)?,
104+
LoginAccountResponse::ChatgptAuthTokens {}
105+
);
106+
107+
let thread_id = app_server
108+
.send_thread_start_request(ThreadStartParams {
109+
model: Some("mock-model".to_string()),
110+
..Default::default()
111+
})
112+
.await?;
113+
let thread_response: JSONRPCResponse = timeout(
114+
DEFAULT_READ_TIMEOUT,
115+
app_server.read_stream_until_response_message(RequestId::Integer(thread_id)),
116+
)
117+
.await??;
118+
let ThreadStartResponse { thread, .. } = to_response(thread_response)?;
119+
120+
let turn_id = app_server
121+
.send_turn_start_request(TurnStartParams {
122+
thread_id: thread.id,
123+
input: vec![UserInput::Text {
124+
text: "suggest a plugin".to_string(),
125+
text_elements: Vec::new(),
126+
}],
127+
..Default::default()
128+
})
129+
.await?;
130+
let _: JSONRPCResponse = timeout(
131+
DEFAULT_READ_TIMEOUT,
132+
app_server.read_stream_until_response_message(RequestId::Integer(turn_id)),
133+
)
134+
.await??;
135+
timeout(
136+
DEFAULT_READ_TIMEOUT,
137+
app_server.read_stream_until_notification_message("turn/completed"),
138+
)
139+
.await??;
140+
141+
let requests = responses_mock.requests();
142+
let request = requests
143+
.iter()
144+
.find(|request| {
145+
request
146+
.message_input_texts("user")
147+
.iter()
148+
.any(|text| text.contains("suggest a plugin"))
149+
})
150+
.expect("turn request");
151+
let contextual_user_message = request.message_input_texts("user").join("\n");
152+
assert!(contextual_user_message.contains("<recommended_plugins>"));
153+
assert!(contextual_user_message.contains("- GitHub (github@openai-curated-remote)"));
154+
let body = request.body_json();
155+
let tool_names = body
156+
.get("tools")
157+
.and_then(Value::as_array)
158+
.into_iter()
159+
.flatten()
160+
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
161+
.collect::<Vec<_>>();
162+
assert!(tool_names.contains(&"request_plugin_install"));
163+
assert!(!tool_names.contains(&"list_available_plugins_to_install"));
164+
Ok(())
165+
}

codex-rs/core-plugins/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ codex-model-provider = { workspace = true }
2727
codex-otel = { workspace = true }
2828
codex-plugin = { workspace = true }
2929
codex-protocol = { workspace = true }
30+
codex-tools = { workspace = true }
3031
codex-utils-absolute-path = { workspace = true }
3132
codex-utils-path-uri = { workspace = true }
3233
codex-utils-plugins = { workspace = true }

codex-rs/core-plugins/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub use manager::PluginReadRequest;
4949
pub use manager::PluginUninstallError;
5050
pub use manager::PluginsConfigInput;
5151
pub use manager::PluginsManager;
52+
pub use manager::RecommendedPluginCandidatesInput;
5253
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
5354
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
5455
pub use provider::ExecutorPluginProvider;

codex-rs/core-plugins/src/manager.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ use codex_config::ConfigLayerStack;
5757
use codex_config::clear_user_plugin;
5858
use codex_config::set_user_plugin_enabled;
5959
use codex_config::types::PluginConfig;
60+
use codex_config::types::ToolSuggestDisabledTool;
61+
use codex_config::types::ToolSuggestDiscoverableType;
6062
use codex_core_skills::SkillMetadata;
6163
use codex_core_skills::config_rules::SkillConfigRules;
6264
use codex_core_skills::config_rules::skill_config_rules_from_stack;
@@ -71,6 +73,9 @@ use codex_plugin::app_connector_ids_from_declarations;
7173
use codex_plugin::prompt_safe_plugin_description;
7274
use codex_protocol::protocol::HookEventName;
7375
use codex_protocol::protocol::Product;
76+
use codex_tools::DiscoverablePluginInfo;
77+
use codex_tools::DiscoverableTool;
78+
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
7479
use codex_utils_absolute_path::AbsolutePathBuf;
7580
use codex_utils_plugins::PluginSkillRoot;
7681
use std::collections::HashMap;
@@ -114,6 +119,15 @@ impl PluginsConfigInput {
114119
}
115120
}
116121

122+
/// Inputs used to select endpoint-backed plugin install candidates.
123+
pub struct RecommendedPluginCandidatesInput<'a> {
124+
pub plugins_config: &'a PluginsConfigInput,
125+
pub loaded_plugins: &'a PluginLoadOutcome,
126+
pub auth: Option<&'a CodexAuth>,
127+
pub disabled_tools: &'a [ToolSuggestDisabledTool],
128+
pub app_server_client_name: Option<&'a str>,
129+
}
130+
117131
#[derive(Clone, PartialEq, Eq)]
118132
struct FeaturedPluginIdsCacheKey {
119133
chatgpt_base_url: String,
@@ -997,6 +1011,59 @@ impl PluginsManager {
9971011
mode
9981012
}
9991013

1014+
/// Returns endpoint recommendations eligible for installation in the current client.
1015+
/// `None` selects the legacy discovery workflow.
1016+
pub async fn recommended_plugin_candidates_for_config(
1017+
&self,
1018+
input: RecommendedPluginCandidatesInput<'_>,
1019+
) -> Option<Vec<DiscoverableTool>> {
1020+
let RecommendedPluginsMode::Endpoint { plugins } = self
1021+
.recommended_plugins_mode_for_config(input.plugins_config, input.auth)
1022+
.await
1023+
else {
1024+
return None;
1025+
};
1026+
if plugins.is_empty() {
1027+
return Some(Vec::new());
1028+
}
1029+
1030+
let installed_plugin_ids = input
1031+
.loaded_plugins
1032+
.plugins()
1033+
.iter()
1034+
.map(|plugin| plugin.config_name.as_str())
1035+
.collect::<HashSet<_>>();
1036+
let disabled_plugin_ids = input
1037+
.disabled_tools
1038+
.iter()
1039+
.filter(|tool| tool.kind == ToolSuggestDiscoverableType::Plugin)
1040+
.map(|tool| tool.id.as_str())
1041+
.collect::<HashSet<_>>();
1042+
1043+
let candidates = plugins
1044+
.into_iter()
1045+
.filter(|plugin| {
1046+
!installed_plugin_ids.contains(plugin.config_id.as_str())
1047+
&& !disabled_plugin_ids.contains(plugin.config_id.as_str())
1048+
})
1049+
.map(|plugin| {
1050+
DiscoverableTool::from(DiscoverablePluginInfo {
1051+
id: plugin.config_id,
1052+
remote_plugin_id: Some(plugin.remote_plugin_id),
1053+
name: plugin.display_name,
1054+
description: None,
1055+
has_skills: false,
1056+
mcp_server_names: Vec::new(),
1057+
app_connector_ids: plugin.app_connector_ids,
1058+
})
1059+
})
1060+
.collect();
1061+
Some(filter_request_plugin_install_discoverable_tools_for_client(
1062+
candidates,
1063+
input.app_server_client_name,
1064+
))
1065+
}
1066+
10001067
fn cached_recommended_plugins_mode(
10011068
&self,
10021069
cache_key: &RecommendedPluginsCacheKey,

codex-rs/core-plugins/src/manager_tests.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3844,6 +3844,79 @@ remote_plugin = true
38443844
);
38453845
}
38463846

3847+
#[tokio::test]
3848+
async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() {
3849+
let tmp = tempfile::tempdir().unwrap();
3850+
write_file(
3851+
&tmp.path().join(CONFIG_TOML_FILE),
3852+
r#"[features]
3853+
plugins = true
3854+
remote_plugin = true
3855+
"#,
3856+
);
3857+
write_cached_plugin(tmp.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear");
3858+
3859+
let server = MockServer::start().await;
3860+
Mock::given(method("GET"))
3861+
.and(path("/ps/plugins/suggested"))
3862+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3863+
"enabled": true,
3864+
"plugins": [
3865+
{
3866+
"id": "plugin_linear",
3867+
"name": "linear",
3868+
"release": {"display_name": "Linear"}
3869+
},
3870+
{
3871+
"id": "plugin_github",
3872+
"name": "github",
3873+
"release": {"display_name": "GitHub"}
3874+
},
3875+
{
3876+
"id": "plugin_slack",
3877+
"name": "slack",
3878+
"release": {"display_name": "Slack"}
3879+
}
3880+
]
3881+
})))
3882+
.expect(1)
3883+
.mount(&server)
3884+
.await;
3885+
3886+
let mut config = load_config(tmp.path(), tmp.path()).await;
3887+
config.chatgpt_base_url = server.uri();
3888+
let manager = PluginsManager::new(tmp.path().to_path_buf());
3889+
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]);
3890+
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
3891+
let disabled_tools = [ToolSuggestDisabledTool::plugin(
3892+
"github@openai-curated-remote",
3893+
)];
3894+
let loaded_plugins = manager.plugins_for_config(&config).await;
3895+
3896+
let candidates = manager
3897+
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
3898+
plugins_config: &config,
3899+
loaded_plugins: &loaded_plugins,
3900+
auth: Some(&auth),
3901+
disabled_tools: &disabled_tools,
3902+
app_server_client_name: None,
3903+
})
3904+
.await;
3905+
3906+
assert_eq!(
3907+
candidates,
3908+
Some(vec![DiscoverableTool::from(DiscoverablePluginInfo {
3909+
id: "slack@openai-curated-remote".to_string(),
3910+
remote_plugin_id: Some("plugin_slack".to_string()),
3911+
name: "Slack".to_string(),
3912+
description: None,
3913+
has_skills: false,
3914+
mcp_server_names: Vec::new(),
3915+
app_connector_ids: Vec::new(),
3916+
})])
3917+
);
3918+
}
3919+
38473920
#[tokio::test]
38483921
async fn recommended_plugins_mode_caches_explicit_false() {
38493922
let tmp = tempfile::tempdir().unwrap();

codex-rs/core/src/context/contextual_user_message.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use super::InternalModelContextFragment;
1010
use super::LegacyApplyPatchExecCommandWarning;
1111
use super::LegacyModelMismatchWarning;
1212
use super::LegacyUnifiedExecProcessLimitWarning;
13+
use super::RecommendedPluginsInstructions;
1314
use super::SkillInstructions;
1415
use super::SubagentNotification;
1516
use super::TurnAborted;
@@ -33,6 +34,8 @@ static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy<SubagentNot
3334
static INTERNAL_MODEL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<
3435
InternalModelContextFragment,
3536
> = FragmentRegistrationProxy::new();
37+
static RECOMMENDED_PLUGINS_REGISTRATION: FragmentRegistrationProxy<RecommendedPluginsInstructions> =
38+
FragmentRegistrationProxy::new();
3639
static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy<
3740
LegacyUnifiedExecProcessLimitWarning,
3841
> = FragmentRegistrationProxy::new();
@@ -52,6 +55,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[
5255
&TURN_ABORTED_REGISTRATION,
5356
&SUBAGENT_NOTIFICATION_REGISTRATION,
5457
&INTERNAL_MODEL_CONTEXT_REGISTRATION,
58+
&RECOMMENDED_PLUGINS_REGISTRATION,
5559
&LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION,
5660
&LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION,
5761
&LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION,

0 commit comments

Comments
 (0)