mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[plugins] Refresh plugin and tool caches after remote install (#28951)
Summary - Refresh the installed remote-plugin snapshot and Codex Apps tools after completing a remote JIT install. - Gate `completed: true` on every expected `app_connector_id` appearing after the uncached `tools/list` refresh, while continuing to skip local bundle verification for server-side installs. - Keep the cached recommendations response and filter refreshed installed remote IDs locally, so this does not add another recommendations fetch. - Add regression coverage for tools appearing after the hard refresh and remaining absent after the refresh. The resumed model request sees the refreshed tool router when installation completes. Root Cause - Remote suggestions from `openai-curated-remote` returned `true` before taking the existing connector refresh path, leaving the resumed turn with the pre-install Apps tool catalog. Validation - `just test -p codex-core request_plugin_install` - `just test -p codex-core-plugins recommended_plugin_candidates_filter_installed_and_disabled_plugins` - `just test -p codex-core-plugins` - `just fix -p codex-core-plugins` - `just fix -p codex-core` - `just fmt` - `just test -p codex-core` was not fully clean locally: 2,729 passed, 26 failed, and 16 skipped. The failures were dominated by local Seatbelt/network/timing issues, including plugin-install timeouts under full-suite contention; the focused plugin-install runs pass.
This commit is contained in:
committed by
GitHub
Unverified
parent
5c12034e42
commit
7e37354a58
@@ -39,6 +39,7 @@ use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
|
||||
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
||||
use crate::marketplace_upgrade::configured_git_marketplace_names;
|
||||
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
|
||||
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use crate::remote::RecommendedPluginsMode;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::remote::RemotePluginCatalogError;
|
||||
@@ -1098,6 +1099,19 @@ impl PluginsManager {
|
||||
.iter()
|
||||
.map(|plugin| plugin.config_name.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let installed_remote_plugin_ids = {
|
||||
let cache = match self.remote_installed_plugins_cache.read() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
cache
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME)
|
||||
.map(|plugin| plugin.id.clone())
|
||||
.collect::<HashSet<_>>()
|
||||
};
|
||||
let disabled_plugin_ids = input
|
||||
.disabled_tools
|
||||
.iter()
|
||||
@@ -1109,6 +1123,7 @@ impl PluginsManager {
|
||||
.into_iter()
|
||||
.filter(|plugin| {
|
||||
!installed_plugin_ids.contains(plugin.config_id.as_str())
|
||||
&& !installed_remote_plugin_ids.contains(plugin.remote_plugin_id.as_str())
|
||||
&& !disabled_plugin_ids.contains(plugin.config_id.as_str())
|
||||
})
|
||||
.map(|plugin| {
|
||||
|
||||
@@ -4464,8 +4464,6 @@ plugins = true
|
||||
remote_plugin = true
|
||||
"#,
|
||||
);
|
||||
write_cached_plugin(tmp.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear");
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/suggested"))
|
||||
@@ -4496,7 +4494,9 @@ remote_plugin = true
|
||||
let mut config = load_config(tmp.path(), tmp.path()).await;
|
||||
config.chatgpt_base_url = server.uri();
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]);
|
||||
let mut installed_linear = remote_installed_plugin("linear");
|
||||
installed_linear.id = "plugin_linear".to_string();
|
||||
manager.write_remote_installed_plugins_cache(vec![installed_linear]);
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let disabled_tools = [ToolSuggestDisabledTool::plugin(
|
||||
"github@openai-curated-remote",
|
||||
|
||||
@@ -328,7 +328,27 @@ async fn verify_request_plugin_install_completed(
|
||||
}),
|
||||
DiscoverableTool::Plugin(plugin) => {
|
||||
if is_remote_plugin_install_suggestion(&plugin.id) {
|
||||
return true;
|
||||
let (_, accessible_connectors) = tokio::join!(
|
||||
refresh_remote_installed_plugins_cache_after_install(
|
||||
session,
|
||||
turn,
|
||||
auth,
|
||||
plugin.id.as_str(),
|
||||
),
|
||||
refresh_missing_requested_connectors(
|
||||
session,
|
||||
turn,
|
||||
auth,
|
||||
&plugin.app_connector_ids,
|
||||
plugin.id.as_str(),
|
||||
)
|
||||
);
|
||||
return accessible_connectors.is_some_and(|accessible_connectors| {
|
||||
all_requested_connectors_picked_up(
|
||||
&plugin.app_connector_ids,
|
||||
&accessible_connectors,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
@@ -351,6 +371,29 @@ async fn verify_request_plugin_install_completed(
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_remote_installed_plugins_cache_after_install(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
auth: Option<&codex_login::CodexAuth>,
|
||||
tool_id: &str,
|
||||
) {
|
||||
let plugins_manager = &session.services.plugins_manager;
|
||||
let plugins_config = turn.config.plugins_config_input();
|
||||
if let Err(err) = plugins_manager
|
||||
.build_and_cache_remote_installed_plugin_marketplaces(
|
||||
&plugins_config,
|
||||
auth,
|
||||
&[REMOTE_GLOBAL_MARKETPLACE_NAME],
|
||||
/*on_effective_plugins_changed*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"failed to refresh remote installed plugins cache after plugin install request for {tool_id}: {err:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool {
|
||||
plugin_id
|
||||
.rsplit_once('@')
|
||||
|
||||
@@ -7,6 +7,8 @@ use codex_login::CodexAuth;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::Request;
|
||||
@@ -59,6 +61,13 @@ pub enum AppsTestToolLoading {
|
||||
Searchable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AppsTestToolsListBehavior {
|
||||
AlwaysAvailable,
|
||||
AvailableAfterInitialList,
|
||||
AlwaysUnavailable,
|
||||
}
|
||||
|
||||
impl AppsTestServer {
|
||||
pub async fn mount(server: &MockServer) -> Result<Self> {
|
||||
Self::mount_with_connector_name(server, CONNECTOR_NAME).await
|
||||
@@ -73,6 +82,7 @@ impl AppsTestServer {
|
||||
CONNECTOR_DESCRIPTION.to_string(),
|
||||
/*searchable*/ true,
|
||||
/*include_app_only_tool*/ false,
|
||||
AppsTestToolsListBehavior::AlwaysAvailable,
|
||||
)
|
||||
.await;
|
||||
Ok(Self {
|
||||
@@ -92,6 +102,7 @@ impl AppsTestServer {
|
||||
CONNECTOR_DESCRIPTION.to_string(),
|
||||
/*searchable*/ false,
|
||||
/*include_app_only_tool*/ false,
|
||||
AppsTestToolsListBehavior::AlwaysAvailable,
|
||||
)
|
||||
.await;
|
||||
Ok(Self {
|
||||
@@ -111,6 +122,42 @@ impl AppsTestServer {
|
||||
CONNECTOR_DESCRIPTION.to_string(),
|
||||
matches!(tool_loading, AppsTestToolLoading::Searchable),
|
||||
/*include_app_only_tool*/ true,
|
||||
AppsTestToolsListBehavior::AlwaysAvailable,
|
||||
)
|
||||
.await;
|
||||
Ok(Self {
|
||||
chatgpt_base_url: server.uri(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mount_with_tools_available_after_initial_list(
|
||||
server: &MockServer,
|
||||
) -> Result<Self> {
|
||||
Self::mount_with_tools_list_behavior(
|
||||
server,
|
||||
AppsTestToolsListBehavior::AvailableAfterInitialList,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mount_without_tools(server: &MockServer) -> Result<Self> {
|
||||
Self::mount_with_tools_list_behavior(server, AppsTestToolsListBehavior::AlwaysUnavailable)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mount_with_tools_list_behavior(
|
||||
server: &MockServer,
|
||||
tools_list_behavior: AppsTestToolsListBehavior,
|
||||
) -> Result<Self> {
|
||||
mount_oauth_metadata(server).await;
|
||||
mount_connectors_directory(server).await;
|
||||
mount_streamable_http_json_rpc(
|
||||
server,
|
||||
CONNECTOR_NAME.to_string(),
|
||||
CONNECTOR_DESCRIPTION.to_string(),
|
||||
/*searchable*/ false,
|
||||
/*include_app_only_tool*/ false,
|
||||
tools_list_behavior,
|
||||
)
|
||||
.await;
|
||||
Ok(Self {
|
||||
@@ -264,6 +311,7 @@ async fn mount_streamable_http_json_rpc(
|
||||
connector_description: String,
|
||||
searchable: bool,
|
||||
include_app_only_tool: bool,
|
||||
tools_list_behavior: AppsTestToolsListBehavior,
|
||||
) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex("^/api/codex/apps/?$"))
|
||||
@@ -272,6 +320,8 @@ async fn mount_streamable_http_json_rpc(
|
||||
connector_description,
|
||||
searchable,
|
||||
include_app_only_tool,
|
||||
tools_list_behavior,
|
||||
tools_list_calls: AtomicUsize::new(0),
|
||||
})
|
||||
.mount(server)
|
||||
.await;
|
||||
@@ -282,6 +332,8 @@ struct CodexAppsJsonRpcResponder {
|
||||
connector_description: String,
|
||||
searchable: bool,
|
||||
include_app_only_tool: bool,
|
||||
tools_list_behavior: AppsTestToolsListBehavior,
|
||||
tools_list_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Respond for CodexAppsJsonRpcResponder {
|
||||
@@ -327,6 +379,12 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
}
|
||||
"notifications/initialized" => ResponseTemplate::new(202),
|
||||
"tools/list" => {
|
||||
let list_index = self.tools_list_calls.fetch_add(1, Ordering::SeqCst);
|
||||
let tools_available = match self.tools_list_behavior {
|
||||
AppsTestToolsListBehavior::AlwaysAvailable => true,
|
||||
AppsTestToolsListBehavior::AvailableAfterInitialList => list_index > 0,
|
||||
AppsTestToolsListBehavior::AlwaysUnavailable => false,
|
||||
};
|
||||
let id = body.get("id").cloned().unwrap_or(Value::Null);
|
||||
let mut response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -428,7 +486,15 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
"nextCursor": null
|
||||
}
|
||||
});
|
||||
if self.searchable
|
||||
if !tools_available
|
||||
&& let Some(tools) = response
|
||||
.pointer_mut("/result/tools")
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
tools.clear();
|
||||
}
|
||||
if tools_available
|
||||
&& self.searchable
|
||||
&& let Some(tools) = response
|
||||
.pointer_mut("/result/tools")
|
||||
.and_then(Value::as_array_mut)
|
||||
@@ -455,7 +521,8 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
}));
|
||||
}
|
||||
}
|
||||
if self.include_app_only_tool
|
||||
if tools_available
|
||||
&& self.include_app_only_tool
|
||||
&& let Some(tools) = response
|
||||
.pointer_mut("/result/tools")
|
||||
.and_then(Value::as_array_mut)
|
||||
|
||||
@@ -11,6 +11,7 @@ use codex_login::CodexAuth;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_protocol::approvals::ElicitationAction;
|
||||
use codex_protocol::approvals::ElicitationRequest;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
@@ -38,6 +39,7 @@ use core_test_support::wait_for_event_match;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockGuard;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
@@ -47,6 +49,11 @@ const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
|
||||
const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install";
|
||||
const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
|
||||
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
|
||||
const REMOTE_CALENDAR_PLUGIN_CONFIG_ID: &str = "calendar@openai-curated-remote";
|
||||
const REMOTE_CALENDAR_PLUGIN_ID: &str = "plugin_calendar";
|
||||
const CALENDAR_CONNECTOR_ID: &str = "calendar";
|
||||
const CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar";
|
||||
const CALENDAR_CREATE_EVENT_TOOL: &str = "_create_event";
|
||||
|
||||
fn tool_names(body: &Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
@@ -133,6 +140,134 @@ async fn build_test(
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
async fn start_install_turn(test: &TestCodex, prompt: &str) -> Result<ElicitationRequestEvent> {
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: ThreadSettingsOverrides {
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
permission_profile,
|
||||
collaboration_mode: Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: test.session_configured.model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::ElicitationRequest(request) => Some(request.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn resolve_install_elicitation(
|
||||
test: &TestCodex,
|
||||
elicitation: ElicitationRequestEvent,
|
||||
decision: ElicitationAction,
|
||||
) -> Result<()> {
|
||||
test.codex
|
||||
.submit(Op::ResolveElicitation {
|
||||
server_name: elicitation.server_name,
|
||||
request_id: elicitation.id,
|
||||
decision,
|
||||
content: None,
|
||||
meta: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mount_remote_calendar_recommendation(server: &wiremock::MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/suggested"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"enabled": true,
|
||||
"plugins": [{
|
||||
"id": REMOTE_CALENDAR_PLUGIN_ID,
|
||||
"name": "calendar",
|
||||
"status": "ENABLED",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"release": {
|
||||
"display_name": "Calendar",
|
||||
"app_ids": [CALENDAR_CONNECTOR_ID]
|
||||
}
|
||||
}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn remote_installed_plugins_response(plugins: Vec<Value>) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": plugins,
|
||||
"pagination": {
|
||||
"next_page_token": null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn mount_empty_remote_installed_plugins(server: &wiremock::MockServer) -> MockGuard {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/installed"))
|
||||
.respond_with(remote_installed_plugins_response(Vec::new()))
|
||||
.mount_as_scoped(server)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mount_remote_calendar_installed_plugins(server: &wiremock::MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/installed"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.respond_with(remote_installed_plugins_response(vec![json!({
|
||||
"id": REMOTE_CALENDAR_PLUGIN_ID,
|
||||
"name": "calendar",
|
||||
"scope": "GLOBAL",
|
||||
"status": "ENABLED",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
"display_name": "Calendar",
|
||||
"description": "Manage calendar events.",
|
||||
"interface": {}
|
||||
},
|
||||
"enabled": true
|
||||
})]))
|
||||
.with_priority(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
for scope in ["WORKSPACE", "USER"] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/installed"))
|
||||
.and(query_param("scope", scope))
|
||||
.respond_with(remote_installed_plugins_response(Vec::new()))
|
||||
.with_priority(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn explicit_false_preserves_legacy_workflow() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -278,6 +413,10 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
run_remote_plugin_install_metadata_case().await
|
||||
}
|
||||
|
||||
async fn run_remote_plugin_install_metadata_case() -> Result<()> {
|
||||
const REMOTE_PLUGIN_ID: &str = "plugin_connector_github";
|
||||
const APP_CONNECTOR_ID: &str = "connector_github";
|
||||
|
||||
@@ -325,40 +464,7 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad
|
||||
)
|
||||
.await;
|
||||
let test = build_test(&server, &apps_server).await?;
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "use GitHub".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: ThreadSettingsOverrides {
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
permission_profile,
|
||||
collaboration_mode: Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: test.session_configured.model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
let elicitation = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::ElicitationRequest(request) => Some(request.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let elicitation = start_install_turn(&test, "use GitHub").await?;
|
||||
let ElicitationRequest::Form {
|
||||
meta: Some(meta), ..
|
||||
} = &elicitation.request
|
||||
@@ -368,19 +474,7 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad
|
||||
assert_eq!(meta["remote_plugin_id"], REMOTE_PLUGIN_ID);
|
||||
assert_eq!(meta["app_connector_ids"], json!([APP_CONNECTOR_ID]));
|
||||
|
||||
test.codex
|
||||
.submit(Op::ResolveElicitation {
|
||||
server_name: elicitation.server_name,
|
||||
request_id: elicitation.id,
|
||||
decision: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
resolve_install_elicitation(&test, elicitation, ElicitationAction::Decline).await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
@@ -392,6 +486,104 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RefreshedAppsTools {
|
||||
Available,
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_plugin_install_refreshes_plugin_and_apps_tool_caches() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
run_remote_plugin_install_refresh_case(RefreshedAppsTools::Available).await?;
|
||||
run_remote_plugin_install_refresh_case(RefreshedAppsTools::Missing).await
|
||||
}
|
||||
|
||||
async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTools) -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = match refreshed_tools {
|
||||
RefreshedAppsTools::Available => {
|
||||
AppsTestServer::mount_with_tools_available_after_initial_list(&server).await?
|
||||
}
|
||||
RefreshedAppsTools::Missing => AppsTestServer::mount_without_tools(&server).await?,
|
||||
};
|
||||
mount_remote_calendar_recommendation(&server).await;
|
||||
let initial_remote_installed_plugins = mount_empty_remote_installed_plugins(&server).await;
|
||||
|
||||
let install_call_id = "install-calendar";
|
||||
let suggest_reason = "Use Calendar for this request";
|
||||
let mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
install_call_id,
|
||||
REQUEST_PLUGIN_INSTALL_TOOL_NAME,
|
||||
&serde_json::to_string(&json!({
|
||||
"plugin_id": REMOTE_CALENDAR_PLUGIN_CONFIG_ID,
|
||||
"suggest_reason": suggest_reason
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let test = build_test(&server, &apps_server).await?;
|
||||
|
||||
let elicitation = start_install_turn(&test, "use Calendar").await?;
|
||||
mount_remote_calendar_installed_plugins(&server).await;
|
||||
drop(initial_remote_installed_plugins);
|
||||
resolve_install_elicitation(&test, elicitation, ElicitationAction::Accept).await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
requests[0]
|
||||
.tool_by_name(CALENDAR_NAMESPACE, CALENDAR_CREATE_EVENT_TOOL)
|
||||
.is_none(),
|
||||
"calendar tool should be absent before the remote install"
|
||||
);
|
||||
let completed = matches!(refreshed_tools, RefreshedAppsTools::Available);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
&requests[1]
|
||||
.function_call_output_text(install_call_id)
|
||||
.expect("install tool output")
|
||||
)?,
|
||||
json!({
|
||||
"completed": completed,
|
||||
"user_confirmed": true,
|
||||
"tool_type": "plugin",
|
||||
"action_type": "install",
|
||||
"tool_id": REMOTE_CALENDAR_PLUGIN_CONFIG_ID,
|
||||
"tool_name": "Calendar",
|
||||
"suggest_reason": suggest_reason
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.tool_by_name(CALENDAR_NAMESPACE, CALENDAR_CREATE_EVENT_TOOL)
|
||||
.is_some(),
|
||||
completed,
|
||||
"the resumed router should reflect the refreshed Apps tools"
|
||||
);
|
||||
assert!(
|
||||
!tool_names(&requests[1].body_json())
|
||||
.iter()
|
||||
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
|
||||
"the refreshed installed-plugin cache should filter the cached recommendation"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn endpoint_mode_with_no_eligible_candidates_exposes_no_suggestion_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
Reference in New Issue
Block a user