mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
48e415bdef
commit
a364dd8b56
@@ -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, };
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -102,6 +102,7 @@ impl AppServerClient {
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user