Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions codex-rs/app-server-protocol/schema/json/ClientRequest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, };
4 changes: 3 additions & 1 deletion codex-rs/app-server-protocol/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
88 changes: 88 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand Down
6 changes: 5 additions & 1 deletion codex-rs/app-server-protocol/src/protocol/v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server-test-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ impl CodexClient {
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: None,
}),
},
};
Expand Down
39 changes: 39 additions & 0 deletions codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
17 changes: 13 additions & 4 deletions codex-rs/app-server/src/message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions codex-rs/app-server/src/outgoing_message.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;

Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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))
Expand All @@ -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:?}");
Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server/tests/common/mcp_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ impl McpProcess {
client_info,
Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: None,
}),
)
.await
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/app-server/tests/suite/v2/experimental_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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?;
Expand Down
Loading
Loading