Expose disabled plugin settings in the app-server API (#44905)

## What changed

- Accept `disabledPluginIds` in `thread/settings/update` and `turn/start`. A supplied list replaces the saved selection; omission or `null` preserves it, and `[]` clears it.
- Return the selection in thread start, resume, and fork responses and `thread/settings/updated` notifications. Update generated schemas and client types.
- Restore disabled plugin IDs from the history retained at the requested fork boundary, preserving explicit overrides.

The selection persists across resume but does not yet filter plugin capabilities.

## Testing

Add coverage for replacing, preserving, and clearing selections without inference; turn-start notifications; resume; and fork boundaries across legacy and paginated history, with loaded and restarted parents. Add a core regression test for history restoration and explicit clearing.

GitOrigin-RevId: 654a8c2a0527228d422c0dd4919228447e2663db
This commit is contained in:
Matthew Zeng authored and copyberry committed 2026-09-11 20:27:37 +00:00
1 parent 42cd1ec497
commit c62d191c4c
44 files changed
+549 -19

No files matched your search

@@ -5,12 +5,21 @@ use app_test_support::TestAppServer;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::write_models_cache;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
@@ -35,10 +44,188 @@ use serde_json::Value;
use serde_json::json;
use std::time::Duration;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn disabled_plugin_ids_replace_preserve_and_clear_without_inference() -> Result<()> {
let server = responses::start_mock_server().await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let started = start_thread(&mut mcp).await?;
assert_eq!(started.disabled_plugin_ids, Vec::<String>::new());
for (mut params, expected) in [
(
json!({"disabledPluginIds": ["slack@openai", "notion@openai"]}),
vec!["slack@openai", "notion@openai"],
),
(
json!({"disabledPluginIds": ["slack@openai"]}),
vec!["slack@openai"],
),
(json!({"model": "mock-model-2"}), vec!["slack@openai"]),
(
json!({"disabledPluginIds": null, "model": "mock-model-3"}),
vec!["slack@openai"],
),
(json!({"disabledPluginIds": []}), vec![]),
] {
params["threadId"] = json!(started.thread.id);
let request_id = mcp
.send_raw_request("thread/settings/update", Some(params))
.await?;
let _: ThreadSettingsUpdateResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
let updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(updated.thread_settings.disabled_plugin_ids, expected);
}
assert!(received_response_bodies(&server).await?.is_empty());
Ok(())
}
#[test_case(ThreadHistoryMode::Legacy, true; "cold legacy parent")]
#[test_case(ThreadHistoryMode::Legacy, false; "loaded legacy parent")]
#[test_case(ThreadHistoryMode::Paginated, true; "cold paginated parent")]
#[test_case(ThreadHistoryMode::Paginated, false; "loaded paginated parent")]
#[tokio::test]
async fn disabled_plugin_ids_restore_from_fork_boundary(
history_mode: ThreadHistoryMode,
restart_before_fork: bool,
) -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("done")?,
create_final_assistant_message_sse_response("done again")?,
])
.await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let started = mcp
.start_thread(ThreadStartParams {
history_mode: Some(history_mode),
..Default::default()
})
.await?;
let thread_id = started.thread.id;
let initial_selection = vec!["slack@openai".to_string()];
let first_turn = mcp
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread_id.clone(),
disabled_plugin_ids: Some(initial_selection.clone()),
input: vec![V2UserInput::Text {
text: "materialize the thread".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
assert_eq!(
read_thread_settings_updated(&mut mcp)
.await?
.thread_settings
.disabled_plugin_ids,
initial_selection
);
let second_turn = mcp
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread_id.clone(),
input: vec![V2UserInput::Text {
text: "continue beyond the fork cutoff".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
// Clearing after the second turn must not affect forks at an earlier boundary.
let current_selection = Vec::<String>::new();
send_thread_settings_update(
&mut mcp,
ThreadSettingsUpdateParams {
thread_id: thread_id.clone(),
disabled_plugin_ids: Some(current_selection.clone()),
..Default::default()
},
)
.await?;
assert_eq!(
read_thread_settings_updated(&mut mcp)
.await?
.thread_settings
.disabled_plugin_ids,
current_selection
);
if restart_before_fork {
mcp.shutdown_gracefully().await?;
mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
}
for (params, expected_selection) in [
(
ThreadForkParams {
thread_id: thread_id.clone(),
..Default::default()
},
&current_selection,
),
(
ThreadForkParams {
thread_id: thread_id.clone(),
last_turn_id: Some(first_turn.turn.id.clone()),
..Default::default()
},
&initial_selection,
),
(
ThreadForkParams {
thread_id: thread_id.clone(),
before_turn_id: Some(second_turn.turn.id),
..Default::default()
},
&initial_selection,
),
(
ThreadForkParams {
thread_id: thread_id.clone(),
last_turn_id: Some(first_turn.turn.id),
// Selection restoration must not depend on restoring permissions.
approval_policy: Some(AskForApproval::Never),
approvals_reviewer: Some(ApprovalsReviewer::User),
sandbox: Some(SandboxMode::DangerFullAccess),
..Default::default()
},
&initial_selection,
),
] {
let forked: ThreadForkResponse = mcp
.request(|request_id| ClientRequest::ThreadFork { request_id, params })
.await?;
assert_eq!(&forked.disabled_plugin_ids, expected_selection);
}
let resumed: ThreadResumeResponse = mcp
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id,
..Default::default()
},
})
.await?;
assert_eq!(resumed.disabled_plugin_ids, current_selection);
Ok(())
}
#[tokio::test]
async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
@@ -416,6 +603,7 @@ async fn turn_start_settings_override_emits_thread_settings_updated() -> Result<
text_elements: Vec::new(),
}],
model: Some("mock-model-3".to_string()),
disabled_plugin_ids: Some(vec!["slack@openai".to_string()]),
..Default::default()
})
.await?;
@@ -426,6 +614,10 @@ async fn turn_start_settings_override_emits_thread_settings_updated() -> Result<
let updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(updated.thread_id, thread.id);
assert_eq!(updated.thread_settings.model, "mock-model-3");
assert_eq!(
updated.thread_settings.disabled_plugin_ids,
vec!["slack@openai"]
);
timeout(
DEFAULT_TIMEOUT,
@@ -3232,6 +3232,7 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
disabled_plugin_ids: None,
environments: None,
thread_id: thread.id.clone(),
client_user_message_id: None,
@@ -3280,6 +3281,7 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
disabled_plugin_ids: None,
environments: Some(vec![TurnEnvironmentParams {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: second_cwd.abs().into(),