feat: load plugin apps (#13401)

load plugin-apps from `.app.json`.

make apps runtime-mentionable iff `codex_apps` MCP actually exposes
tools for that `connector_id`.

if the app isn't available, it's filtered out of runtime connector set,
so no tools are added and no app-mentions resolve.

right now we don't have a clean cli-side error for an app not being
installed. can look at this after.

### Tests
Added tests, tested locally that using a plugin that bundles an app
picks up the app.
This commit is contained in:
sayan-oai
2026-03-03 16:29:15 -08:00
committed by GitHub
parent c4cb594e73
commit 082682a628
7 changed files with 444 additions and 13 deletions

View File

@@ -7,11 +7,14 @@ use std::time::Instant;
use anyhow::Result;
use codex_core::CodexAuth;
use codex_core::features::Feature;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
@@ -74,6 +77,35 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
.expect("write config");
}
fn write_plugin_app_plugin(home: &TempDir) {
let plugin_root = home.path().join("plugins/sample");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "calendar"
}
}
}"#,
)
.expect("write plugin app config");
std::fs::write(
home.path().join("config.toml"),
format!(
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
plugin_root.display()
),
)
.expect("write config");
}
async fn build_plugin_test_codex(
server: &MockServer,
codex_home: Arc<TempDir>,
@@ -88,6 +120,23 @@ async fn build_plugin_test_codex(
.codex)
}
fn tool_names(body: &serde_json::Value) -> Vec<String> {
body.get("tools")
.and_then(serde_json::Value::as_array)
.map(|tools| {
tools
.iter()
.filter_map(|tool| {
tool.get("name")
.or_else(|| tool.get("type"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.collect()
})
.unwrap_or_default()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_skills_append_to_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -138,6 +187,86 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
],
)
.await;
let codex_home = Arc::new(TempDir::new()?);
write_plugin_app_plugin(codex_home.as_ref());
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config.features.enable(Feature::Apps);
config.features.disable(Feature::AppsMcpGateway);
config.chatgpt_base_url = apps_server.chatgpt_base_url;
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.codex;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "Use $google-calendar and then call tools.".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let requests = mock.requests();
assert_eq!(requests.len(), 2, "expected two model requests");
let first_tools = tool_names(&requests[0].body_json());
assert!(
!first_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"app tools should stay hidden before plugin app mention: {first_tools:?}"
);
let second_tools = tool_names(&requests[1].body_json());
assert!(
second_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"calendar create tool should be available after plugin app mention: {second_tools:?}"
);
assert!(
second_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
"calendar list tool should be available after plugin app mention: {second_tools:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn plugin_mcp_tools_are_listed() -> Result<()> {
skip_if_no_network!(Ok(()));