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:
Matthew Zeng
2026-09-08 19:56:25 +00:00
committed by copyberry
parent f31bd3adff
commit 6d377e96eb
11 changed files with 370 additions and 22 deletions
@@ -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,