diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a36ebc6a09e..de746e7a78d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b7e922dd17a..88fb5ddbdb3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index dd71c717f1a..050bcb9c506 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index 24f53026278..a6ac24efcdf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -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 | null, }; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 5c4954b3cc0..2cb3f9d1f1c 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -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") { diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 77d22f4abf3..84523286931 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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")?; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 09b4130b5da..25e60ca7516 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -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>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 90f6adf572f..415f45e4a19 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -511,6 +511,7 @@ impl CodexClient { }, capabilities: Some(InitializeCapabilities { experimental_api: true, + opt_out_notification_methods: None, }), }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 18bf4184114..7608262077c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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`. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 75ca41ea7ee..368d24e7270 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index be89775d86e..b64bd5bce95 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -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, request_id_to_callback: Mutex>>, + opted_out_notification_methods: Mutex>, } 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) { + 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:?}"); diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index c1d004d4071..7f77d8fc92a 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -174,6 +174,7 @@ impl McpProcess { client_info, Some(InitializeCapabilities { experimental_api: true, + opt_out_notification_methods: None, }), ) .await diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 5116633a480..798d52abfd0 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -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?; diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index b31a68833f3..2edc83a7f49 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -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, diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index cf54ef9855c..cacb5a77326 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -102,6 +102,7 @@ impl AppServerClient { }, capabilities: Some(InitializeCapabilities { experimental_api: true, + opt_out_notification_methods: None, }), }, };