mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
Propagate Apps tool refreshes to existing threads (#43900)
## Why Refreshing installed Apps without a thread should update the tools available to existing threads on their next turn. ## What changed - Publish live tool catalogs to clients with matching transport, auth, protocol, and listing settings within the same account and home directory. Ignore the thread attribution header when matching scopes. - Adopt updated tools before catalog reads and new calls, preserving running calls and rejecting calls prepared against an outdated catalog revision. - Keep the newest successful fetch per scope so older refreshes cannot overwrite newer tools, and exclude disk snapshots from live updates. ## Testing Add regression coverage for refresh propagation to an existing thread without another tools listing, scope and account isolation, out-of-order refreshes, late client startup, and running versus stale prepared calls. GitOrigin-RevId: 7a5ee34e23742ce374c6647dc8928b76ea622448
This commit is contained in:
@@ -77,7 +77,10 @@ async fn test_step(
|
||||
.await
|
||||
.expect("create in-process MCP client"),
|
||||
);
|
||||
let tool_catalog = Arc::new(ClientToolCatalog::new(vec![tool.clone()]));
|
||||
let tool_catalog = Arc::new(ClientToolCatalog::new(
|
||||
vec![tool.clone()],
|
||||
/*updates*/ None,
|
||||
));
|
||||
let managed_client = Arc::new(ManagedClient {
|
||||
_auth_change_notifications: None,
|
||||
client: Arc::clone(&client),
|
||||
|
||||
@@ -4,17 +4,23 @@
|
||||
//! catalog. Snapshot reads and revision-checked calls keep the locks private;
|
||||
//! successful refreshes publish after calls using the current catalog finish.
|
||||
//! Refresh results retain raw tools and eligibility from the same published runtime.
|
||||
//! Optional live updates are adopted before reading or starting another call.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_connectors::ConnectorRuntimeSnapshot;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::RwLockReadGuard;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::tools::ToolInfo;
|
||||
|
||||
type ToolCatalogUpdates = watch::Receiver<Option<Arc<ConnectorRuntimeSnapshot<ToolInfo>>>>;
|
||||
|
||||
/// The exact Apps catalog returned by an awaited refresh of one published runtime.
|
||||
pub struct CodexAppsToolSnapshot {
|
||||
/// Raw installed tools, including tools hidden or disabled for the model.
|
||||
@@ -31,24 +37,65 @@ pub(crate) struct ClientToolCatalog {
|
||||
}
|
||||
|
||||
pub(crate) struct ToolCatalogSnapshot {
|
||||
/// Zero is the startup catalog; successful explicit refreshes advance it.
|
||||
/// Advances on explicit refresh or adoption of changed live tools.
|
||||
pub(crate) revision: u64,
|
||||
pub(crate) tools: Vec<ToolInfo>,
|
||||
updates: Option<ToolCatalogUpdates>,
|
||||
}
|
||||
|
||||
impl ClientToolCatalog {
|
||||
pub(crate) fn new(tools: Vec<ToolInfo>) -> Self {
|
||||
pub(crate) fn new(tools: Vec<ToolInfo>, mut updates: Option<ToolCatalogUpdates>) -> Self {
|
||||
let tools = updates
|
||||
.as_mut()
|
||||
.and_then(|updates| {
|
||||
updates
|
||||
.borrow_and_update()
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.tools().to_vec())
|
||||
})
|
||||
.unwrap_or(tools);
|
||||
Self {
|
||||
current: RwLock::new(ToolCatalogSnapshot { revision: 0, tools }),
|
||||
current: RwLock::new(ToolCatalogSnapshot {
|
||||
revision: 0,
|
||||
tools,
|
||||
updates,
|
||||
}),
|
||||
refresh_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read<R>(&self, read: impl FnOnce(&ToolCatalogSnapshot) -> R) -> R {
|
||||
let current = self.current.read().await;
|
||||
let current = self.read_current().await;
|
||||
read(¤t)
|
||||
}
|
||||
|
||||
async fn read_current(&self) -> RwLockReadGuard<'_, ToolCatalogSnapshot> {
|
||||
loop {
|
||||
{
|
||||
let current = self.current.read().await;
|
||||
if !current
|
||||
.updates
|
||||
.as_ref()
|
||||
.is_some_and(|updates| updates.has_changed().unwrap_or(false))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
}
|
||||
let mut current = self.current.write().await;
|
||||
if let Some(updates) = current.updates.as_mut()
|
||||
&& updates.has_changed().unwrap_or(false)
|
||||
{
|
||||
let snapshot = updates.borrow_and_update().clone();
|
||||
if let Some(snapshot) = snapshot
|
||||
&& current.tools != snapshot.tools()
|
||||
{
|
||||
current.tools = snapshot.tools().to_vec();
|
||||
current.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize fetching and publication, leaving the current catalog usable during the fetch.
|
||||
/// The publication callback runs alongside the exact-client update under the write lock.
|
||||
#[expect(
|
||||
@@ -65,7 +112,16 @@ impl ClientToolCatalog {
|
||||
let (tools, context) = fetch().await?;
|
||||
let mut current = self.current.write().await;
|
||||
let result = publish(&tools, context);
|
||||
current.tools = tools;
|
||||
current.tools = current
|
||||
.updates
|
||||
.as_mut()
|
||||
.and_then(|updates| {
|
||||
updates
|
||||
.borrow_and_update()
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.tools().to_vec())
|
||||
})
|
||||
.unwrap_or(tools);
|
||||
current.revision += 1;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -84,7 +140,7 @@ impl ClientToolCatalog {
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = R>,
|
||||
{
|
||||
let current = self.current.read().await;
|
||||
let current = self.read_current().await;
|
||||
if current.revision != expected_revision {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -336,8 +336,28 @@ impl McpConnectionSet {
|
||||
let shares_codex_apps_tools_cache = is_host_owned_codex_apps
|
||||
&& should_share_codex_apps_tools_cache(&server_name, uses_env_bearer_token);
|
||||
let codex_apps_tools_cache_context = shares_codex_apps_tools_cache.then(|| {
|
||||
// Tools/list has no thread selection or UI capabilities. Only equivalent
|
||||
// transport/auth and listing settings may share executable Apps tools.
|
||||
let mut transport = configured_config.transport.clone();
|
||||
if let McpServerTransportConfig::StreamableHttp {
|
||||
http_headers: Some(headers),
|
||||
..
|
||||
} = &mut transport
|
||||
{
|
||||
// mcp_server_config_for_url in codex-rs/codex-mcp/src/mcp/mod.rs
|
||||
// adds thread attribution that threadless discovery does not carry.
|
||||
headers.retain(|name, _| !name.eq_ignore_ascii_case("originator"));
|
||||
}
|
||||
let mut scope = serde_json::json!([
|
||||
transport,
|
||||
&configured_config.auth,
|
||||
protocol_mode.preferred_protocol_version().as_str(),
|
||||
catalog_item_limit,
|
||||
]);
|
||||
scope.sort_all_objects();
|
||||
codex_apps_tools_cache
|
||||
.context(codex_home.clone(), codex_apps_tools_cache_key.clone())
|
||||
.with_live_scope(scope.to_string())
|
||||
});
|
||||
// The reserved Codex Apps registration follows the shared
|
||||
// AuthManager across refreshes. In the hosted-plugin path, this
|
||||
|
||||
@@ -446,7 +446,7 @@ impl McpConnectionSet {
|
||||
.client()
|
||||
.await
|
||||
.context("failed to get client")?;
|
||||
let (client_tools, tools, list_start) = managed_client
|
||||
let (tools, list_start) = managed_client
|
||||
.tool_catalog
|
||||
.refresh(
|
||||
|| async {
|
||||
@@ -474,8 +474,8 @@ impl McpConnectionSet {
|
||||
Ok((client_tools, (fetch_ticket, list_start)))
|
||||
},
|
||||
|client_tools, (fetch_ticket, list_start)| {
|
||||
// Discovery may accept another client's newer fetch. The catalog
|
||||
// retains the tools fetched through this exact connection.
|
||||
// Discovery can accept another scope's winner; executable catalogs
|
||||
// receive only the latest successful fetch from their own scope.
|
||||
let tools = match (
|
||||
managed_client.codex_apps_tools_cache_context.as_ref(),
|
||||
fetch_ticket,
|
||||
@@ -489,10 +489,14 @@ impl McpConnectionSet {
|
||||
(None, None) => client_tools.to_vec(),
|
||||
_ => unreachable!("Codex Apps fetch ticket requires cache context"),
|
||||
};
|
||||
(client_tools.to_vec(), tools, list_start)
|
||||
(tools, list_start)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let client_tools = managed_client
|
||||
.tool_catalog
|
||||
.read(|catalog| catalog.tools.clone())
|
||||
.await;
|
||||
emit_duration(
|
||||
MCP_TOOLS_LIST_DURATION_METRIC,
|
||||
list_start.elapsed(),
|
||||
|
||||
@@ -458,7 +458,7 @@ async fn create_test_managed_client(tools: Vec<ToolInfo>) -> ManagedClient {
|
||||
.expect("create in-process RMCP client"),
|
||||
),
|
||||
server_info: create_test_server_info("Ready"),
|
||||
tool_catalog: Arc::new(ClientToolCatalog::new(tools)),
|
||||
tool_catalog: Arc::new(ClientToolCatalog::new(tools, /*updates*/ None)),
|
||||
tool_timeout: None,
|
||||
server_instructions: None,
|
||||
server_supports_sandbox_state_meta_capability: false,
|
||||
@@ -758,7 +758,7 @@ pub(crate) async fn create_test_manager_with_ready_apps_client(
|
||||
_auth_change_notifications: None,
|
||||
client,
|
||||
server_info: create_test_server_info("Codex Apps"),
|
||||
tool_catalog: Arc::new(ClientToolCatalog::new(vec![tool])),
|
||||
tool_catalog: Arc::new(ClientToolCatalog::new(vec![tool], /*updates*/ None)),
|
||||
tool_timeout: Some(Duration::from_secs(5)),
|
||||
server_instructions: None,
|
||||
server_supports_sandbox_state_meta_capability: false,
|
||||
@@ -4941,6 +4941,90 @@ async fn reconcile_reusable_server_with_mcp_config(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_catalog_broadcast_preserves_running_calls_and_rejects_stale_calls()
|
||||
-> anyhow::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let context = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
/*account_id*/ None,
|
||||
/*chatgpt_user_id*/ None,
|
||||
)
|
||||
.with_live_scope("apps".to_string());
|
||||
let original = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "original")];
|
||||
let updated = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "updated")];
|
||||
let catalog = ClientToolCatalog::new(original.clone(), context.subscribe());
|
||||
|
||||
store_current_tools(&context, original.clone());
|
||||
assert_eq!(
|
||||
catalog.read(|catalog| catalog.revision).await,
|
||||
0,
|
||||
"an unchanged startup broadcast must not invalidate prepared calls"
|
||||
);
|
||||
|
||||
let (release, released) = tokio::sync::oneshot::channel::<()>();
|
||||
let running = catalog.run_with_revision(
|
||||
/*expected_revision*/ 0,
|
||||
|| async { released.await.unwrap() },
|
||||
);
|
||||
tokio::pin!(running);
|
||||
assert!(futures::poll!(&mut running).is_pending());
|
||||
store_current_tools(&context, updated.clone());
|
||||
let stale = catalog.run_with_revision(
|
||||
/*expected_revision*/ 0,
|
||||
|| async { panic!("stale preparation") },
|
||||
);
|
||||
tokio::pin!(stale);
|
||||
assert!(
|
||||
futures::poll!(&mut stale).is_pending(),
|
||||
"publication does not wait, but adoption must wait for the running call"
|
||||
);
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(running.await, Some(()));
|
||||
assert_eq!(stale.await, None::<()>);
|
||||
assert_eq!(
|
||||
catalog
|
||||
.read(|catalog| (catalog.revision, catalog.tools.clone()))
|
||||
.await,
|
||||
(1, updated.clone())
|
||||
);
|
||||
|
||||
// A client whose startup finishes late adopts the already-published result.
|
||||
let late = ClientToolCatalog::new(original, context.subscribe());
|
||||
assert_eq!(late.read(|catalog| catalog.tools.clone()).await, updated);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_catalog_broadcast_survives_an_older_local_refresh() -> anyhow::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let context = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
/*account_id*/ None,
|
||||
/*chatgpt_user_id*/ None,
|
||||
)
|
||||
.with_live_scope("apps".to_string());
|
||||
let catalog = ClientToolCatalog::new(Vec::new(), context.subscribe());
|
||||
let older_ticket = context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh);
|
||||
let newer = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newer")];
|
||||
store_current_tools(&context, newer.clone());
|
||||
assert_eq!(catalog.read(|catalog| catalog.tools.clone()).await, newer);
|
||||
catalog
|
||||
.refresh(
|
||||
|| async { Ok((Vec::new(), older_ticket)) },
|
||||
|tools, ticket| {
|
||||
context.publish_if_newest_accepted(
|
||||
ticket,
|
||||
&create_test_server_info("Apps"),
|
||||
tools.to_vec(),
|
||||
)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(catalog.read(|catalog| catalog.tools.clone()).await, newer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshed_catalog_follows_reused_client_without_mutating_old_bindings()
|
||||
-> anyhow::Result<()> {
|
||||
@@ -5083,7 +5167,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a
|
||||
_auth_change_notifications: None,
|
||||
client,
|
||||
server_info: create_test_server_info("Mutable tools"),
|
||||
tool_catalog: Arc::new(ClientToolCatalog::new(initial_tools)),
|
||||
tool_catalog: Arc::new(ClientToolCatalog::new(initial_tools, /*updates*/ None)),
|
||||
tool_timeout: None,
|
||||
server_instructions: initialize.instructions,
|
||||
server_supports_sandbox_state_meta_capability: false,
|
||||
|
||||
@@ -1005,7 +1005,12 @@ async fn start_server_task(
|
||||
_auth_change_notifications: auth_change_notifications,
|
||||
client: Arc::clone(&client),
|
||||
server_info,
|
||||
tool_catalog: Arc::new(ClientToolCatalog::new(client_tools)),
|
||||
tool_catalog: Arc::new(ClientToolCatalog::new(
|
||||
client_tools,
|
||||
codex_apps_tools_cache_context
|
||||
.as_ref()
|
||||
.and_then(ConnectorRuntimeContext::subscribe),
|
||||
)),
|
||||
tool_timeout: None,
|
||||
server_instructions: initialize_result.instructions,
|
||||
server_supports_sandbox_state_meta_capability,
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::mcp::sanitize_responses_api_tool_name;
|
||||
|
||||
const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolInfo {
|
||||
/// Raw MCP server name used for routing the tool call.
|
||||
pub server_name: String,
|
||||
|
||||
Reference in New Issue
Block a user