feat: opt-out of events in the app-server (#11319)

Add `optOutNotificationMethods` in the app-server to opt-out events
based on exact method matching
This commit is contained in:
jif-oai
2026-02-10 18:04:52 +00:00
committed by GitHub
Unverified
parent 48e415bdef
commit a364dd8b56
15 changed files with 284 additions and 7 deletions
@@ -718,6 +718,16 @@
"default": false,
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
@@ -5610,6 +5610,16 @@
"default": false,
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
@@ -29,6 +29,16 @@
"default": false,
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
@@ -9,4 +9,9 @@ export type InitializeCapabilities = {
/**
* Opt into receiving experimental API methods and fields.
*/
experimentalApi: boolean, };
experimentalApi: boolean,
/**
* Exact notification method names that should be suppressed for this
* connection (for example `codex/event/session_configured`).
*/
optOutNotificationMethods?: Array<string> | null, };
+3 -1
View File
@@ -1474,7 +1474,9 @@ mod tests {
let allow_optional_nullable = path
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.ends_with("Params"));
.is_some_and(|stem| {
stem.ends_with("Params") || stem == "InitializeCapabilities"
});
let contents = fs::read_to_string(&path)?;
if contents.contains("| undefined") {
@@ -806,6 +806,94 @@ mod tests {
Ok(())
}
#[test]
fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> {
let request = ClientRequest::Initialize {
request_id: RequestId::Integer(42),
params: v1::InitializeParams {
client_info: v1::ClientInfo {
name: "codex_vscode".to_string(),
title: Some("Codex VS Code Extension".to_string()),
version: "0.1.0".to_string(),
},
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"codex/event/session_configured".to_string(),
"item/agentMessage/delta".to_string(),
]),
}),
},
};
assert_eq!(
json!({
"method": "initialize",
"id": 42,
"params": {
"clientInfo": {
"name": "codex_vscode",
"title": "Codex VS Code Extension",
"version": "0.1.0"
},
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"item/agentMessage/delta"
]
}
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> {
let request: ClientRequest = serde_json::from_value(json!({
"method": "initialize",
"id": 42,
"params": {
"clientInfo": {
"name": "codex_vscode",
"title": "Codex VS Code Extension",
"version": "0.1.0"
},
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"item/agentMessage/delta"
]
}
}
}))?;
assert_eq!(
request,
ClientRequest::Initialize {
request_id: RequestId::Integer(42),
params: v1::InitializeParams {
client_info: v1::ClientInfo {
name: "codex_vscode".to_string(),
title: Some("Codex VS Code Extension".to_string()),
version: "0.1.0".to_string(),
},
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"codex/event/session_configured".to_string(),
"item/agentMessage/delta".to_string(),
]),
}),
},
}
);
Ok(())
}
#[test]
fn conversation_id_serializes_as_plain_string() -> Result<()> {
let id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
@@ -46,12 +46,16 @@ pub struct ClientInfo {
}
/// Client-declared capabilities negotiated during initialize.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct InitializeCapabilities {
/// Opt into receiving experimental API methods and fields.
#[serde(default)]
pub experimental_api: bool,
/// Exact notification method names that should be suppressed for this
/// connection (for example `codex/event/session_configured`).
#[ts(optional = nullable)]
pub opt_out_notification_methods: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -511,6 +511,7 @@ impl CodexClient {
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: None,
}),
},
};
+39
View File
@@ -52,6 +52,8 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
Clients must send a single `initialize` request before invoking any other method, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls receive an `"Already initialized"` error.
`initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored.
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
**Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If
@@ -74,6 +76,29 @@ Example (from OpenAI's official VSCode extension):
}
```
Example with notification opt-out:
```json
{
"method": "initialize",
"id": 1,
"params": {
"clientInfo": {
"name": "my_client",
"title": "My Client",
"version": "0.1.0"
},
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"item/agentMessage/delta"
]
}
}
}
```
## API Overview
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
@@ -480,6 +505,20 @@ Notes:
Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `turn/*`, and `item/*` notifications.
### Notification opt-out
Clients can suppress specific notifications per connection by sending exact method names in `initialize.params.capabilities.optOutNotificationMethods`.
- Exact-match only: `item/agentMessage/delta` suppresses only that method.
- Unknown method names are ignored.
- Applies to both legacy (`codex/event/*`) and v2 (`thread/*`, `turn/*`, `item/*`, etc.) notifications.
- Does not apply to requests/responses/errors.
Examples:
- Opt out of legacy session setup event: `codex/event/session_configured`
- Opt out of streamed agent text deltas: `item/agentMessage/delta`
### Turn events
The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`.
+13 -4
View File
@@ -228,12 +228,21 @@ impl MessageProcessor {
self.outgoing.send_error(request_id, error).await;
return;
} else {
let experimental_api_enabled = params
.capabilities
.as_ref()
.is_some_and(|cap| cap.experimental_api);
let (experimental_api_enabled, opt_out_notification_methods) =
match params.capabilities {
Some(capabilities) => (
capabilities.experimental_api,
capabilities
.opt_out_notification_methods
.unwrap_or_default(),
),
None => (false, Vec::new()),
};
self.experimental_api_enabled
.store(experimental_api_enabled, Ordering::Relaxed);
self.outgoing
.set_opted_out_notification_methods(opt_out_notification_methods)
.await;
let ClientInfo {
name,
title: _title,
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
@@ -24,6 +25,7 @@ pub(crate) struct OutgoingMessageSender {
next_request_id: AtomicI64,
sender: mpsc::Sender<OutgoingMessage>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
opted_out_notification_methods: Mutex<HashSet<String>>,
}
impl OutgoingMessageSender {
@@ -32,9 +34,21 @@ impl OutgoingMessageSender {
next_request_id: AtomicI64::new(0),
sender,
request_id_to_callback: Mutex::new(HashMap::new()),
opted_out_notification_methods: Mutex::new(HashSet::new()),
}
}
pub(crate) async fn set_opted_out_notification_methods(&self, methods: Vec<String>) {
let mut opted_out = self.opted_out_notification_methods.lock().await;
opted_out.clear();
opted_out.extend(methods);
}
async fn should_skip_notification(&self, method: &str) -> bool {
let opted_out = self.opted_out_notification_methods.lock().await;
opted_out.contains(method)
}
pub(crate) async fn send_request(
&self,
request: ServerRequestPayload,
@@ -130,6 +144,10 @@ impl OutgoingMessageSender {
}
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
let method = notification.to_string();
if self.should_skip_notification(&method).await {
return;
}
if let Err(err) = self
.sender
.send(OutgoingMessage::AppServerNotification(notification))
@@ -142,6 +160,12 @@ impl OutgoingMessageSender {
/// All notifications should be migrated to [`ServerNotification`] and
/// [`OutgoingMessage::Notification`] should be removed.
pub(crate) async fn send_notification(&self, notification: OutgoingNotification) {
if self
.should_skip_notification(notification.method.as_str())
.await
{
return;
}
let outgoing_message = OutgoingMessage::Notification(notification);
if let Err(err) = self.sender.send(outgoing_message).await {
warn!("failed to send notification to client: {err:?}");
@@ -174,6 +174,7 @@ impl McpProcess {
client_info,
Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: None,
}),
)
.await
@@ -30,6 +30,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
opt_out_notification_methods: None,
}),
)
.await?;
@@ -61,6 +62,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
opt_out_notification_methods: None,
}),
)
.await?;
@@ -97,6 +99,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
opt_out_notification_methods: None,
}),
)
.await?;
@@ -3,8 +3,12 @@ use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
@@ -108,6 +112,72 @@ async fn initialize_rejects_invalid_client_name() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn initialize_opt_out_notification_methods_filters_notifications() -> Result<()> {
let responses = Vec::new();
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let message = timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_capabilities(
ClientInfo {
name: "codex_vscode".to_string(),
title: Some("Codex VS Code Extension".to_string()),
version: "0.1.0".to_string(),
},
Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"thread/started".to_string(),
"codex/event/session_configured".to_string(),
]),
}),
),
)
.await??;
let JSONRPCMessage::Response(_) = message else {
anyhow::bail!("expected initialize response, got {message:?}");
};
let request_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let response = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let message = mcp.read_next_message().await?;
match message {
JSONRPCMessage::Response(response)
if response.id == RequestId::Integer(request_id) =>
{
return Ok(response);
}
JSONRPCMessage::Notification(notification)
if notification.method == "thread/started" =>
{
anyhow::bail!("thread/started should be filtered by optOutNotificationMethods");
}
_ => {}
}
}
})
.await??;
let _: ThreadStartResponse = to_response(response)?;
let thread_started = timeout(
std::time::Duration::from_millis(500),
mcp.read_stream_until_notification_message("thread/started"),
)
.await;
assert!(
thread_started.is_err(),
"thread/started should be filtered by optOutNotificationMethods"
);
Ok(())
}
// Helper to create a config.toml pointing at the mock model server.
fn create_config_toml(
codex_home: &Path,
+1
View File
@@ -102,6 +102,7 @@ impl AppServerClient {
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: None,
}),
},
};