From 92704fa756a1c321efe413175b25bc0f02e5696b Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 4 Nov 2025 10:22:37 -0800 Subject: [PATCH] feat: add v2 json-rpc notifications --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-protocol/Cargo.toml | 1 + codex-rs/app-server-protocol/src/export.rs | 333 +++++++---- .../src/protocol/common.rs | 193 ++++-- .../app-server-protocol/src/protocol/v2.rs | 551 +++++++++++++++++- .../app-server/src/codex_message_processor.rs | 56 +- codex-rs/app-server/src/outgoing_message.rs | 38 +- .../app-server/tests/common/mcp_process.rs | 8 +- codex-rs/app-server/tests/suite/model_list.rs | 36 +- .../app-server/tests/suite/rate_limits.rs | 12 +- 10 files changed, 1009 insertions(+), 220 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4961486e65..bf978581cc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -871,6 +871,7 @@ dependencies = [ "anyhow", "clap", "codex-protocol", + "mcp-types", "paste", "pretty_assertions", "schemars 0.8.22", diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index 58b0b9d607..5aa1c765e7 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -14,6 +14,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-protocol = { workspace = true } +mcp-types = { workspace = true } paste = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 3affa7afca..9692fb95c5 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2,8 +2,12 @@ use crate::ClientNotification; use crate::ClientRequest; use crate::ServerNotification; use crate::ServerRequest; +use crate::export_client_notification_schemas; +use crate::export_client_param_schemas; use crate::export_client_response_schemas; use crate::export_client_responses; +use crate::export_server_notification_schemas; +use crate::export_server_param_schemas; use crate::export_server_response_schemas; use crate::export_server_responses; use anyhow::Context; @@ -28,84 +32,7 @@ use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; -macro_rules! for_each_schema_type { - ($macro:ident) => { - $macro!(crate::RequestId); - $macro!(crate::JSONRPCMessage); - $macro!(crate::JSONRPCRequest); - $macro!(crate::JSONRPCNotification); - $macro!(crate::JSONRPCResponse); - $macro!(crate::JSONRPCError); - $macro!(crate::JSONRPCErrorError); - $macro!(crate::AddConversationListenerParams); - $macro!(crate::AddConversationSubscriptionResponse); - $macro!(crate::ApplyPatchApprovalParams); - $macro!(crate::ApplyPatchApprovalResponse); - $macro!(crate::ArchiveConversationParams); - $macro!(crate::ArchiveConversationResponse); - $macro!(crate::AuthMode); - $macro!(crate::AccountUpdatedNotification); - $macro!(crate::AuthStatusChangeNotification); - $macro!(crate::CancelLoginChatGptParams); - $macro!(crate::CancelLoginChatGptResponse); - $macro!(crate::ClientInfo); - $macro!(crate::ClientNotification); - $macro!(crate::ClientRequest); - $macro!(crate::ConversationSummary); - $macro!(crate::ExecCommandApprovalParams); - $macro!(crate::ExecCommandApprovalResponse); - $macro!(crate::ExecOneOffCommandParams); - $macro!(crate::ExecOneOffCommandResponse); - $macro!(crate::FuzzyFileSearchParams); - $macro!(crate::FuzzyFileSearchResponse); - $macro!(crate::FuzzyFileSearchResult); - $macro!(crate::GetAuthStatusParams); - $macro!(crate::GetAuthStatusResponse); - $macro!(crate::GetUserAgentResponse); - $macro!(crate::GetUserSavedConfigResponse); - $macro!(crate::GitDiffToRemoteParams); - $macro!(crate::GitDiffToRemoteResponse); - $macro!(crate::GitSha); - $macro!(crate::InitializeParams); - $macro!(crate::InitializeResponse); - $macro!(crate::InputItem); - $macro!(crate::InterruptConversationParams); - $macro!(crate::InterruptConversationResponse); - $macro!(crate::ListConversationsParams); - $macro!(crate::ListConversationsResponse); - $macro!(crate::LoginApiKeyParams); - $macro!(crate::LoginApiKeyResponse); - $macro!(crate::LoginChatGptCompleteNotification); - $macro!(crate::LoginChatGptResponse); - $macro!(crate::LogoutChatGptParams); - $macro!(crate::LogoutChatGptResponse); - $macro!(crate::NewConversationParams); - $macro!(crate::NewConversationResponse); - $macro!(crate::Profile); - $macro!(crate::RemoveConversationListenerParams); - $macro!(crate::RemoveConversationSubscriptionResponse); - $macro!(crate::ResumeConversationParams); - $macro!(crate::ResumeConversationResponse); - $macro!(crate::SandboxSettings); - $macro!(crate::SendUserMessageParams); - $macro!(crate::SendUserMessageResponse); - $macro!(crate::SendUserTurnParams); - $macro!(crate::SendUserTurnResponse); - $macro!(crate::ServerNotification); - $macro!(crate::ServerRequest); - $macro!(crate::SessionConfiguredNotification); - $macro!(crate::SetDefaultModelParams); - $macro!(crate::SetDefaultModelResponse); - $macro!(crate::Tools); - $macro!(crate::UserInfoResponse); - $macro!(crate::UserSavedConfig); - $macro!(codex_protocol::protocol::EventMsg); - $macro!(codex_protocol::protocol::FileChange); - $macro!(codex_protocol::parse_command::ParsedCommand); - $macro!(codex_protocol::protocol::SandboxPolicy); - }; -} - +type JsonSchemaEmitter = fn(&Path) -> Result; pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { generate_ts(out_dir, prettier)?; generate_json(out_dir)?; @@ -113,7 +40,9 @@ pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { } pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + let v2_out_dir = out_dir.join("v2"); ensure_dir(out_dir)?; + ensure_dir(&v2_out_dir)?; ClientRequest::export_all_to(out_dir)?; export_client_responses(out_dir)?; @@ -124,12 +53,15 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { ServerNotification::export_all_to(out_dir)?; generate_index_ts(out_dir)?; + generate_index_ts(&v2_out_dir)?; - let ts_files = ts_files_in(out_dir)?; + // Ensure our header is present on all TS files (root + subdirs like v2/). + let ts_files = ts_files_in_recursive(out_dir)?; for file in &ts_files { prepend_header_if_missing(file)?; } + // Optionally run Prettier on all generated TS files. if let Some(prettier_bin) = prettier && !ts_files.is_empty() { @@ -148,20 +80,57 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { pub fn generate_json(out_dir: &Path) -> Result<()> { ensure_dir(out_dir)?; + // Emit schemas for core envelope/wire types that may not be referenced + // by params/responses but are useful as standalone: let mut bundle: BTreeMap = BTreeMap::new(); - macro_rules! add_schema { - ($ty:path) => {{ - let name = type_basename(stringify!($ty)); - let schema = write_json_schema_with_return::<$ty>(out_dir, &name)?; - bundle.insert(name, schema); - }}; + let envelope_emitters: &[(&str, JsonSchemaEmitter)] = &[ + ("RequestId", |d| { + write_json_schema_with_return::(d, "RequestId") + }), + ("JSONRPCMessage", |d| { + write_json_schema_with_return::(d, "JSONRPCMessage") + }), + ("JSONRPCRequest", |d| { + write_json_schema_with_return::(d, "JSONRPCRequest") + }), + ("JSONRPCNotification", |d| { + write_json_schema_with_return::(d, "JSONRPCNotification") + }), + ("JSONRPCResponse", |d| { + write_json_schema_with_return::(d, "JSONRPCResponse") + }), + ("JSONRPCError", |d| { + write_json_schema_with_return::(d, "JSONRPCError") + }), + ("JSONRPCErrorError", |d| { + write_json_schema_with_return::(d, "JSONRPCErrorError") + }), + ("ClientRequest", |d| { + write_json_schema_with_return::(d, "ClientRequest") + }), + ("ServerRequest", |d| { + write_json_schema_with_return::(d, "ServerRequest") + }), + ("ClientNotification", |d| { + write_json_schema_with_return::(d, "ClientNotification") + }), + ("ServerNotification", |d| { + write_json_schema_with_return::(d, "ServerNotification") + }), + ]; + for (name, emit) in envelope_emitters { + let schema = emit(out_dir)?; + bundle.insert((*name).to_string(), schema); } - for_each_schema_type!(add_schema); - + // Have the macros generate per-type JSON for params, responses, and notifications. + export_client_param_schemas(out_dir)?; export_client_response_schemas(out_dir)?; + export_server_param_schemas(out_dir)?; export_server_response_schemas(out_dir)?; + export_client_notification_schemas(out_dir)?; + export_server_notification_schemas(out_dir)?; let mut definitions = Map::new(); @@ -177,10 +146,44 @@ pub fn generate_json(out_dir: &Path) -> Result<()> { "ServerRequest", ]; - for (name, schema) in bundle { - let mut schema_value = serde_json::to_value(schema)?; - annotate_schema(&mut schema_value, Some(name.as_str())); + // Merge all generated per-type JSON files (including the envelopes above) + // into a single definitions bundle. Also recognize a v2 namespace so that + // types like RateLimitSnapshot can co-exist between legacy and v2. + let json_files = json_files_in_recursive(out_dir)?; + for path in json_files { + // Skip the bundle we’re about to (re)generate. + if path.file_name().and_then(OsStr::to_str) + == Some("codex_app_server_protocol.schemas.json") + { + continue; + } + let file_stem = path + .file_stem() + .and_then(OsStr::to_str) + .ok_or_else(|| anyhow!(format!("Invalid schema file name {}", path.display())))?; + + // Determine namespace and logical type name from either the stem + // (e.g., "v2::Type") or the directory layout (e.g., ".../v2/Type.json"). + let (ns_opt, logical_name) = detect_namespace(&path, file_stem); + + let mut schema_value: Value = serde_json::from_str( + &fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?, + ) + .with_context(|| format!("Failed to parse JSON from {}", path.display()))?; + + // Normalize; use the original stem as the base so existing naming rules apply. + annotate_schema(&mut schema_value, Some(file_stem)); + + if let Some(ref ns) = ns_opt { + // Rewrite internal $ref targets to point to the namespaced location + // under the bundle's definitions. + rewrite_refs_to_namespace(&mut schema_value, ns); + } + + // Pull embedded definitions out and insert into our bundle map at the + // appropriate namespace. if let Value::Object(ref mut obj) = schema_value && let Some(defs) = obj.remove("definitions") && let Value::Object(defs_obj) = defs @@ -188,11 +191,37 @@ pub fn generate_json(out_dir: &Path) -> Result<()> { for (def_name, mut def_schema) in defs_obj { if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { annotate_schema(&mut def_schema, Some(def_name.as_str())); - definitions.insert(def_name, def_schema); + if let Some(ref ns) = ns_opt { + definitions + .entry(ns.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(defs_ns) = definitions + .get_mut(ns) + .ok_or_else(|| anyhow!("just inserted v2 namespace"))? + { + defs_ns.insert(def_name, def_schema); + } + } else { + definitions.insert(def_name, def_schema); + } } } } - definitions.insert(name, schema_value); + + // Insert the schema root itself into the bundle under the namespace. + if let Some(ref ns) = ns_opt { + definitions + .entry(ns.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(defs_ns) = definitions + .get_mut(ns) + .ok_or_else(|| anyhow!("just inserted v2 namespace"))? + { + defs_ns.insert(logical_name.to_string(), schema_value); + } + } else { + definitions.insert(logical_name.to_string(), schema_value); + } } let mut root = Map::new(); @@ -223,7 +252,19 @@ where let schema = schema_for!(T); let mut schema_value = serde_json::to_value(schema)?; annotate_schema(&mut schema_value, Some(file_stem)); - write_pretty_json(out_dir.join(format!("{file_stem}.json")), &schema_value) + // If the name looks like a namespaced path (e.g., "v2::Type"), mirror + // the TypeScript layout and write to out_dir/v2/Type.json. Otherwise + // write alongside the legacy files. + let (ns_opt, logical_name) = split_namespace(file_stem); + let out_path = if let Some(ns) = ns_opt { + let dir = out_dir.join(ns); + ensure_dir(&dir)?; + dir.join(format!("{logical_name}.json")) + } else { + out_dir.join(format!("{file_stem}.json")) + }; + + write_pretty_json(out_path, &schema_value) .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; let annotated_schema = serde_json::from_value(schema_value)?; Ok(annotated_schema) @@ -242,13 +283,76 @@ fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> { fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?; Ok(()) } -fn type_basename(type_path: &str) -> String { - type_path - .rsplit_once("::") - .map(|(_, name)| name) - .unwrap_or(type_path) - .trim() - .to_string() + +/// Detect a namespace from a schema file path and stem. +/// Supports both legacy on-disk names like "v2::Type.json" and the new +/// directory layout ".../v2/Type.json". +fn detect_namespace(path: &Path, stem: &str) -> (Option, String) { + // Prefer directory-based detection. + if let Some(parent) = path.parent() + && parent.file_name().and_then(OsStr::to_str) == Some("v2") + { + return (Some("v2".to_string()), stem.to_string()); + } + split_namespace(stem) +} + +/// Split a "ns::Type" name into (Some(ns), Type). Returns (None, name) if no ns. +fn split_namespace(name: &str) -> (Option, String) { + if let Some(idx) = name.find("::") { + let (ns, rest) = name.split_at(idx); + // rest starts with "::" + let typ = &rest[2..]; + return (Some(ns.to_string()), typ.to_string()); + } + (None, name.to_string()) +} + +/// Recursively rewrite $ref values that point at "#/definitions/..." so that +/// they point to a namespaced location under the bundle. +fn rewrite_refs_to_namespace(value: &mut Value, ns: &str) { + match value { + Value::Object(obj) => { + if let Some(Value::String(r)) = obj.get_mut("$ref") + && let Some(suffix) = r.strip_prefix("#/definitions/") + { + *r = format!("#/definitions/{ns}/{suffix}"); + } + for v in obj.values_mut() { + rewrite_refs_to_namespace(v, ns); + } + } + Value::Array(items) => { + for v in items.iter_mut() { + rewrite_refs_to_namespace(v, ns); + } + } + _ => {} + } +} + +fn json_files_in_recursive(dir: &Path) -> Result> { + let mut files = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + for entry in + fs::read_dir(&d).with_context(|| format!("Failed to read dir {}", d.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + // Skip known non-protocol directories. + if path.file_name().and_then(OsStr::to_str) == Some("serde_json") { + continue; + } + stack.push(path); + } else if path.is_file() && path.extension() == Some(OsStr::new("json")) { + files.push(path); + } + } + } + files.sort(); + Ok(files) } fn variant_definition_name(base: &str, variant: &Value) -> Option { @@ -505,6 +609,26 @@ fn ts_files_in(dir: &Path) -> Result> { Ok(files) } +fn ts_files_in_recursive(dir: &Path) -> Result> { + let mut files = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + for entry in + fs::read_dir(&d).with_context(|| format!("Failed to read dir {}", d.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.is_file() && path.extension() == Some(OsStr::new("ts")) { + files.push(path); + } + } + } + files.sort(); + Ok(files) +} + fn generate_index_ts(out_dir: &Path) -> Result { let mut entries: Vec = Vec::new(); let mut stems: Vec = ts_files_in(out_dir)? @@ -521,6 +645,14 @@ fn generate_index_ts(out_dir: &Path) -> Result { entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); } + // If this is the root out_dir and a ./v2 folder exists with TS files, + // expose it as a namespace to avoid symbol collisions at the root. + let v2_dir = out_dir.join("v2"); + let has_v2_ts = ts_files_in(&v2_dir).map(|v| !v.is_empty()).unwrap_or(false); + if has_v2_ts { + entries.push("export * as v2 from \"./v2\";\n".to_string()); + } + let mut content = String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); content.push_str(HEADER); @@ -547,6 +679,7 @@ mod tests { #[test] fn generated_ts_has_no_optional_nullable_fields() -> Result<()> { + // Assert that there are no types of the form "?: T | null" in the generated TS files. let output_dir = std::env::temp_dir().join(format!("codex_ts_types_{}", Uuid::now_v7())); fs::create_dir(&output_dir)?; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index efc60830c2..161bc94b51 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -9,7 +9,6 @@ use crate::protocol::v2; use codex_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxCommandAssessment; use paste::paste; @@ -82,6 +81,15 @@ macro_rules! client_request_definitions { )* Ok(()) } + + pub fn export_client_param_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + $( + crate::export::write_json_schema::<$params>(out_dir, stringify!($params))?; + )* + Ok(()) + } }; } @@ -89,9 +97,9 @@ client_request_definitions! { /// NEW APIs #[serde(rename = "model/list")] #[ts(rename = "model/list")] - ListModels { - params: v2::ListModelsParams, - response: v2::ListModelsResponse, + ModelList { + params: v2::ModelListParams, + response: v2::ModelListResponse, }, #[serde(rename = "account/login")] @@ -117,9 +125,9 @@ client_request_definitions! { #[serde(rename = "feedback/upload")] #[ts(rename = "feedback/upload")] - UploadFeedback { - params: v2::UploadFeedbackParams, - response: v2::UploadFeedbackResponse, + FeedbackUpload { + params: v2::FeedbackUploadParams, + response: v2::FeedbackUploadResponse, }, #[serde(rename = "account/read")] @@ -284,6 +292,15 @@ macro_rules! server_request_definitions { } Ok(()) } + + pub fn export_server_param_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + paste! { + $(crate::export::write_json_schema::<[<$variant Params>]>(out_dir, stringify!([<$variant Params>]))?;)* + } + Ok(()) + } }; } @@ -366,12 +383,123 @@ pub struct FuzzyFileSearchResponse { pub files: Vec, } -/// Notification sent from the server to the client. -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] -#[serde(tag = "method", content = "params", rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum ServerNotification { +/// Generates an enum of server notifications plus helpers. +macro_rules! server_notification_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident ( $payload:ty ) + ),* $(,)? + ) => { + /// Notification sent from the server to the client. + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] + #[serde(tag = "method", content = "params", rename_all = "camelCase")] + #[strum(serialize_all = "camelCase")] + pub enum ServerNotification { + $( + $(#[$variant_meta])* + $variant($payload), + )* + } + + impl ServerNotification { + pub fn to_params(self) -> Result { + match self { + $(Self::$variant(params) => serde_json::to_value(params),)* + } + } + } + + impl TryFrom for ServerNotification { + type Error = serde_json::Error; + + fn try_from(value: JSONRPCNotification) -> Result { + serde_json::from_value(serde_json::to_value(value)?) + } + } + + pub fn export_server_notification_schemas( + _out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + $( + crate::export::write_json_schema::<$payload>(_out_dir, stringify!($payload))?; + )* + Ok(()) + } + }; +} + +/// Generates the enum of client notifications plus helpers. +macro_rules! client_notification_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident $( ( $payload:ty ) )? + ),* $(,)? + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] + #[serde(tag = "method", content = "params", rename_all = "camelCase")] + #[strum(serialize_all = "camelCase")] + pub enum ClientNotification { + $( + $(#[$variant_meta])* + $variant $( ( $payload ) )?, + )* + } + + pub fn export_client_notification_schemas( + _out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + $( + $(crate::export::write_json_schema::<$payload>(_out_dir, stringify!($payload))?;)? + )* + Ok(()) + } + }; +} + +server_notification_definitions! { /// NEW NOTIFICATIONS + #[serde(rename = "thread/started")] + #[ts(rename = "thread/started")] + #[strum(serialize = "thread/started")] + ThreadStarted(v2::ThreadStartedNotification), + + #[serde(rename = "turn/started")] + #[ts(rename = "turn/started")] + #[strum(serialize = "turn/started")] + TurnStarted(v2::TurnStartedNotification), + + #[serde(rename = "turn/completed")] + #[ts(rename = "turn/completed")] + #[strum(serialize = "turn/completed")] + TurnCompleted(v2::TurnCompletedNotification), + + #[serde(rename = "item/started")] + #[ts(rename = "item/started")] + #[strum(serialize = "item/started")] + ItemStarted(v2::ItemStartedNotification), + + #[serde(rename = "item/completed")] + #[ts(rename = "item/completed")] + #[strum(serialize = "item/completed")] + ItemCompleted(v2::ItemCompletedNotification), + + #[serde(rename = "item/agentMessage/delta")] + #[ts(rename = "item/agentMessage/delta")] + #[strum(serialize = "item/agentMessage/delta")] + AgentMessageDelta(v2::AgentMessageDeltaNotification), + + #[serde(rename = "item/commandExecution/outputDelta")] + #[ts(rename = "item/commandExecution/outputDelta")] + #[strum(serialize = "item/commandExecution/outputDelta")] + CommandExecutionOutputDelta(v2::CommandExecutionOutputDeltaNotification), + + #[serde(rename = "item/mcpToolCall/progress")] + #[ts(rename = "item/mcpToolCall/progress")] + #[strum(serialize = "item/mcpToolCall/progress")] + McpToolCallProgress(v2::McpToolCallProgressNotification), + #[serde(rename = "account/updated")] #[ts(rename = "account/updated")] #[strum(serialize = "account/updated")] @@ -380,45 +508,16 @@ pub enum ServerNotification { #[serde(rename = "account/rateLimits/updated")] #[ts(rename = "account/rateLimits/updated")] #[strum(serialize = "account/rateLimits/updated")] - AccountRateLimitsUpdated(RateLimitSnapshot), + AccountRateLimitsUpdated(v2::AccountRateLimitsUpdatedNotification), /// DEPRECATED NOTIFICATIONS below - /// Authentication status changed AuthStatusChange(v1::AuthStatusChangeNotification), - - /// ChatGPT login flow completed LoginChatGptComplete(v1::LoginChatGptCompleteNotification), - - /// The special session configured event for a new or resumed conversation. SessionConfigured(v1::SessionConfiguredNotification), } -impl ServerNotification { - pub fn to_params(self) -> Result { - match self { - ServerNotification::AccountUpdated(params) => serde_json::to_value(params), - ServerNotification::AccountRateLimitsUpdated(params) => serde_json::to_value(params), - ServerNotification::AuthStatusChange(params) => serde_json::to_value(params), - ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params), - ServerNotification::SessionConfigured(params) => serde_json::to_value(params), - } - } -} - -impl TryFrom for ServerNotification { - type Error = serde_json::Error; - - fn try_from(value: JSONRPCNotification) -> Result { - serde_json::from_value(serde_json::to_value(value)?) - } -} - -/// Notification sent from the client to the server. -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] -#[serde(tag = "method", content = "params", rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum ClientNotification { - Initialized, +client_notification_definitions! { + Initialized } #[cfg(test)] @@ -671,16 +770,16 @@ mod tests { #[test] fn serialize_list_models() -> Result<()> { - let request = ClientRequest::ListModels { + let request = ClientRequest::ModelList { request_id: RequestId::Integer(6), - params: v2::ListModelsParams::default(), + params: v2::ModelListParams::default(), }; assert_eq!( json!({ "method": "model/list", "id": 6, "params": { - "pageSize": null, + "limit": null, "cursor": null } }), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8c39628412..5e12efa6a7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1,17 +1,123 @@ +use std::collections::HashMap; +use std::path::PathBuf; + use crate::protocol::common::AuthMode; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningEffort; -use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::config_types::ReasoningSummary; +use mcp_types::ContentBlock as McpContentBlock; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json::Value as JsonValue; use ts_rs::TS; use uuid::Uuid; +// Macro to declare a camelCased API v2 enum mirroring a core enum which +// tends to use kebab-case. +macro_rules! v2_enum_from_core { + ( + pub enum $Name:ident from $Src:path { $( $Variant:ident ),+ $(,)? } + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] + #[serde(rename_all = "camelCase")] + #[ts(export_to = "v2/")] + pub enum $Name { $( $Variant ),+ } + + impl $Name { + pub fn to_core(self) -> $Src { + match self { $( $Name::$Variant => <$Src>::$Variant ),+ } + } + } + + impl From<$Src> for $Name { + fn from(value: $Src) -> Self { + match value { $( <$Src>::$Variant => $Name::$Variant ),+ } + } + } + }; +} + +v2_enum_from_core!( + pub enum AskForApproval from codex_protocol::protocol::AskForApproval { + UnlessTrusted, OnFailure, OnRequest, Never + } +); + +v2_enum_from_core!( + pub enum SandboxMode from codex_protocol::config_types::SandboxMode { + ReadOnly, WorkspaceWrite, DangerFullAccess + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "camelCase")] +#[ts(tag = "mode")] +#[ts(export_to = "v2/")] +pub enum SandboxPolicy { + DangerFullAccess, + ReadOnly, + WorkspaceWrite { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + writable_roots: Vec, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +impl SandboxPolicy { + pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { + match self { + SandboxPolicy::DangerFullAccess => { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + } + SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly, + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + } + } +} + +impl From for SandboxPolicy { + fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { + match value { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess + } + codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] +#[ts(export_to = "v2/")] pub enum Account { #[serde(rename = "apiKey", rename_all = "camelCase")] #[ts(rename = "apiKey", rename_all = "camelCase")] @@ -28,6 +134,7 @@ pub enum Account { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type")] #[ts(tag = "type")] +#[ts(export_to = "v2/")] pub enum LoginAccountParams { #[serde(rename = "apiKey")] #[ts(rename = "apiKey")] @@ -43,6 +150,7 @@ pub enum LoginAccountParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct LoginAccountResponse { /// Only set if the login method is ChatGPT. #[schemars(with = "String")] @@ -55,31 +163,36 @@ pub struct LoginAccountResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct LogoutAccountResponse {} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct GetAccountRateLimitsResponse { pub rate_limits: RateLimitSnapshot, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct GetAccountResponse { pub account: Account, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ListModelsParams { - /// Optional page size; defaults to a reasonable server-side value. - pub page_size: Option, +#[ts(export_to = "v2/")] +pub struct ModelListParams { /// Opaque pagination cursor returned by a previous call. pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + pub limit: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct Model { pub id: String, pub model: String, @@ -93,6 +206,7 @@ pub struct Model { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct ReasoningEffortOption { pub reasoning_effort: ReasoningEffort, pub description: String, @@ -100,8 +214,9 @@ pub struct ReasoningEffortOption { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ListModelsResponse { - pub items: Vec, +#[ts(export_to = "v2/")] +pub struct ModelListResponse { + pub data: Vec, /// Opaque cursor to pass to the next call to continue after the last item. /// if None, there are no more items to return. pub next_cursor: Option, @@ -109,7 +224,8 @@ pub struct ListModelsResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct UploadFeedbackParams { +#[ts(export_to = "v2/")] +pub struct FeedbackUploadParams { pub classification: String, pub reason: Option, pub conversation_id: Option, @@ -118,10 +234,429 @@ pub struct UploadFeedbackParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct UploadFeedbackResponse { +#[ts(export_to = "v2/")] +pub struct FeedbackUploadResponse { + pub thread_id: String, +} + +// === Threads, Turns, and Items === +// Thread APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartParams { + pub model: Option, + pub model_provider: Option, + pub cwd: Option, + pub approval_policy: Option, + pub sandbox: Option, + pub config: Option>, + pub base_instructions: Option, + pub developer_instructions: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeParams { pub thread_id: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListParams { + /// Opaque pagination cursor returned by a previous call. + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + pub limit: Option, + /// Optional provider filter; when set, only sessions recorded under these + /// providers are returned. When present but empty, includes all providers. + pub model_providers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Thread { + pub id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Turn { + pub id: String, + pub items: Vec, + pub status: TurnStatus, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnError { + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnStatus { + Completed, + Interrupted, + Failed, + InProgress, +} + +// Turn APIs + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartParams { + pub thread_id: String, + pub input: Vec, + /// Override the working directory for this turn and subsequent turns. + pub cwd: Option, + /// Override the approval policy for this turn and subsequent turns. + pub approval_policy: Option, + /// Override the sandbox policy for this turn and subsequent turns. + pub sandbox_policy: Option, + /// Override the model for this turn and subsequent turns. + pub model: Option, + /// Override the reasoning effort for this turn and subsequent turns. + pub effort: Option, + /// Override the reasoning summary for this turn and subsequent turns. + pub summary: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartResponse { + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptParams { + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptResponse {} + +// User input types +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum UserInput { + Text { text: String }, + Image { url: String }, + LocalImage { path: PathBuf }, +} + +// Thread items + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadItem { + UserMessage { + id: String, + content: Vec, + }, + AgentMessage { + id: String, + text: String, + }, + Reasoning { + id: String, + text: String, + }, + CommandExecution { + id: String, + command: String, + aggregated_output: String, + exit_code: Option, + status: CommandExecutionStatus, + duration_ms: Option, + }, + FileChange { + id: String, + changes: Vec, + status: PatchApplyStatus, + }, + McpToolCall { + id: String, + server: String, + tool: String, + status: McpToolCallStatus, + arguments: JsonValue, + result: Option, + error: Option, + }, + WebSearch { + id: String, + query: String, + }, + TodoList { + id: String, + items: Vec, + }, + ImageView { + id: String, + path: String, + }, + CodeReview { + id: String, + review: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileUpdateChange { + pub path: String, + pub kind: PatchChangeKind, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchChangeKind { + Add, + Delete, + Update, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchApplyStatus { + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallResult { + pub content: Vec, + pub structured_content: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallError { + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TodoItem { + pub id: String, + pub text: String, + pub completed: bool, +} + +// === Server Notifications === +// Thread/Turn lifecycle notifications and item progress events +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartedNotification { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartedNotification { + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Usage { + pub input_tokens: i32, + pub cached_input_tokens: i32, + pub output_tokens: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnCompletedNotification { + pub turn: Turn, + // TODO: should usage be stored on the Turn object, and we return that instead? + pub usage: Usage, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemStartedNotification { + pub item: ThreadItem, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemCompletedNotification { + pub item: ThreadItem, +} + +// Item-specific progress notifications +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AgentMessageDeltaNotification { + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionOutputDeltaNotification { + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallProgressNotification { + pub item_id: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountRateLimitsUpdatedNotification { + pub rate_limits: RateLimitSnapshot, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitSnapshot { + pub primary: Option, + pub secondary: Option, +} + +impl From for RateLimitSnapshot { + fn from(value: codex_protocol::protocol::RateLimitSnapshot) -> Self { + Self { + primary: value.primary.map(RateLimitWindow::from), + secondary: value.secondary.map(RateLimitWindow::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitWindow { + /// Percentage (0-100) of the window that has been consumed. + pub used_percent: i64, + /// Rolling window duration, in minutes. + #[ts(type = "number | null")] + pub window_duration_mins: Option, + /// Unix timestamp (seconds since epoch) when the window resets. + #[ts(type = "number | null")] + pub resets_at: Option, +} + +impl From for RateLimitWindow { + fn from(value: codex_protocol::protocol::RateLimitWindow) -> Self { + Self { + used_percent: value.used_percent.round() as i64, + window_duration_mins: value.window_minutes, + resets_at: value.resets_at, + } + } +} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AccountUpdatedNotification { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8ea4cef8ba..0cfd510946 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4,6 +4,7 @@ use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; @@ -19,6 +20,8 @@ use codex_app_server_protocol::ExecCommandApprovalParams; use codex_app_server_protocol::ExecCommandApprovalResponse; use codex_app_server_protocol::ExecOneOffCommandParams; use codex_app_server_protocol::ExecOneOffCommandResponse; +use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; @@ -33,14 +36,15 @@ use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::ListConversationsResponse; -use codex_app_server_protocol::ListModelsParams; -use codex_app_server_protocol::ListModelsResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::LoginChatGptResponse; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::NewConversationResponse; +use codex_app_server_protocol::RateLimitSnapshot as V2RateLimitSnapshot; use codex_app_server_protocol::RemoveConversationListenerParams; use codex_app_server_protocol::RemoveConversationSubscriptionResponse; use codex_app_server_protocol::RequestId; @@ -55,8 +59,6 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; -use codex_app_server_protocol::UploadFeedbackParams; -use codex_app_server_protocol::UploadFeedbackResponse; use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserSavedConfig; use codex_backend_client::Client as BackendClient; @@ -97,7 +99,7 @@ use codex_protocol::ConversationId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; @@ -187,7 +189,7 @@ impl CodexMessageProcessor { ClientRequest::ListConversations { request_id, params } => { self.handle_list_conversations(request_id, params).await; } - ClientRequest::ListModels { request_id, params } => { + ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } ClientRequest::LoginAccount { @@ -288,7 +290,7 @@ impl CodexMessageProcessor { } => { self.get_account_rate_limits(request_id).await; } - ClientRequest::UploadFeedback { request_id, params } => { + ClientRequest::FeedbackUpload { request_id, params } => { self.upload_feedback(request_id, params).await; } } @@ -634,7 +636,7 @@ impl CodexMessageProcessor { } } - async fn fetch_account_rate_limits(&self) -> Result { + async fn fetch_account_rate_limits(&self) -> Result { let Some(auth) = self.auth_manager.auth() else { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -662,6 +664,7 @@ impl CodexMessageProcessor { client .get_rate_limits() .await + .map(|rl: CoreRateLimitSnapshot| rl.into()) .map_err(|err| JSONRPCErrorError { code: INTERNAL_ERROR_CODE, message: format!("failed to fetch codex rate limits: {err}"), @@ -982,21 +985,28 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } - async fn list_models(&self, request_id: RequestId, params: ListModelsParams) { - let ListModelsParams { page_size, cursor } = params; - let models = supported_models(); + async fn list_models(&self, request_id: RequestId, params: ModelListParams) { + let ModelListParams { cursor, limit } = params; + + let mut models = supported_models(); + + // Sort models in descending order by id (default behavior). + models.sort_by(|a, b| b.id.cmp(&a.id)); + let total = models.len(); if total == 0 { - let response = ListModelsResponse { - items: Vec::new(), + let response = ModelListResponse { + data: Vec::new(), next_cursor: None, }; self.outgoing.send_response(request_id, response).await; return; } - let effective_page_size = page_size.unwrap_or(total).max(1).min(total); + // Determine pagination window + let default_limit = total as i32; + let effective_limit = limit.unwrap_or(default_limit).max(1).min(default_limit) as usize; let start = match cursor { Some(cursor) => match cursor.parse::() { Ok(idx) => idx, @@ -1023,14 +1033,15 @@ impl CodexMessageProcessor { return; } - let end = start.saturating_add(effective_page_size).min(total); - let items = models[start..end].to_vec(); + let end = start.saturating_add(effective_limit).min(total); + let data = models[start..end].to_vec(); let next_cursor = if end < total { Some(end.to_string()) } else { None }; - let response = ListModelsResponse { items, next_cursor }; + + let response = ModelListResponse { data, next_cursor }; self.outgoing.send_response(request_id, response).await; } @@ -1634,8 +1645,8 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } - async fn upload_feedback(&self, request_id: RequestId, params: UploadFeedbackParams) { - let UploadFeedbackParams { + async fn upload_feedback(&self, request_id: RequestId, params: FeedbackUploadParams) { + let FeedbackUploadParams { classification, reason, conversation_id, @@ -1680,7 +1691,7 @@ impl CodexMessageProcessor { match upload_result { Ok(()) => { - let response = UploadFeedbackResponse { thread_id }; + let response = FeedbackUploadResponse { thread_id }; self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -1764,9 +1775,12 @@ async fn apply_bespoke_event_handling( } EventMsg::TokenCount(token_count_event) => { if let Some(rate_limits) = token_count_event.rate_limits { + let snapshot: V2RateLimitSnapshot = rate_limits.into(); outgoing .send_server_notification(ServerNotification::AccountRateLimitsUpdated( - rate_limits, + AccountRateLimitsUpdatedNotification { + rate_limits: snapshot, + }, )) .await; } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 3d56c9bd83..6563c1ff17 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -144,8 +144,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::LoginChatGptCompleteNotification; - use codex_protocol::protocol::RateLimitSnapshot; - use codex_protocol::protocol::RateLimitWindow; + use codex_app_server_protocol::RateLimitSnapshot; + use codex_app_server_protocol::RateLimitWindow; use pretty_assertions::assert_eq; use serde_json::json; use uuid::Uuid; @@ -179,26 +179,32 @@ mod tests { #[test] fn verify_account_rate_limits_notification_serialization() { - let notification = ServerNotification::AccountRateLimitsUpdated(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 25.0, - window_minutes: Some(15), - resets_at: Some(123), - }), - secondary: None, - }); + let notification = ServerNotification::AccountRateLimitsUpdated( + codex_app_server_protocol::AccountRateLimitsUpdatedNotification { + rate_limits: RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 25, + window_duration_mins: Some(15), + resets_at: Some(123), + }), + secondary: None, + }, + }, + ); let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); assert_eq!( json!({ "method": "account/rateLimits/updated", "params": { - "primary": { - "used_percent": 25.0, - "window_minutes": 15, - "resets_at": 123, - }, - "secondary": null, + "rateLimits": { + "primary": { + "usedPercent": 25, + "windowDurationMins": 15, + "resetsAt": 123 + }, + "secondary": null + } }, }), serde_json::to_value(jsonrpc_notification) diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 3c1fbf777c..66ca721436 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -21,7 +21,7 @@ use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::ListConversationsParams; -use codex_app_server_protocol::ListModelsParams; +use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::RemoveConversationListenerParams; @@ -30,7 +30,7 @@ use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserTurnParams; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SetDefaultModelParams; -use codex_app_server_protocol::UploadFeedbackParams; +use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; @@ -246,7 +246,7 @@ impl McpProcess { /// Send a `feedback/upload` JSON-RPC request. pub async fn send_upload_feedback_request( &mut self, - params: UploadFeedbackParams, + params: FeedbackUploadParams, ) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); self.send_request("feedback/upload", params).await @@ -278,7 +278,7 @@ impl McpProcess { /// Send a `model/list` JSON-RPC request. pub async fn send_list_models_request( &mut self, - params: ListModelsParams, + params: ModelListParams, ) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); self.send_request("model/list", params).await diff --git a/codex-rs/app-server/tests/suite/model_list.rs b/codex-rs/app-server/tests/suite/model_list.rs index 3472e0d3cb..f855c31312 100644 --- a/codex-rs/app-server/tests/suite/model_list.rs +++ b/codex-rs/app-server/tests/suite/model_list.rs @@ -6,8 +6,8 @@ use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::ListModelsParams; -use codex_app_server_protocol::ListModelsResponse; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; @@ -27,8 +27,8 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_list_models_request(ListModelsParams { - page_size: Some(100), + .send_list_models_request(ModelListParams { + limit: Some(100), cursor: None, }) .await?; @@ -39,7 +39,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { ) .await??; - let ListModelsResponse { items, next_cursor } = to_response::(response)?; + let ModelListResponse { data, next_cursor } = to_response::(response)?; let expected_models = vec![ Model { @@ -98,7 +98,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { }, ]; - assert_eq!(items, expected_models); + assert_eq!(data, expected_models); assert!(next_cursor.is_none()); Ok(()) } @@ -111,8 +111,8 @@ async fn list_models_pagination_works() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let first_request = mcp - .send_list_models_request(ListModelsParams { - page_size: Some(1), + .send_list_models_request(ModelListParams { + limit: Some(1), cursor: None, }) .await?; @@ -123,18 +123,18 @@ async fn list_models_pagination_works() -> Result<()> { ) .await??; - let ListModelsResponse { - items: first_items, + let ModelListResponse { + data: first_items, next_cursor: first_cursor, - } = to_response::(first_response)?; + } = to_response::(first_response)?; assert_eq!(first_items.len(), 1); assert_eq!(first_items[0].id, "gpt-5-codex"); let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?; let second_request = mcp - .send_list_models_request(ListModelsParams { - page_size: Some(1), + .send_list_models_request(ModelListParams { + limit: Some(1), cursor: Some(next_cursor.clone()), }) .await?; @@ -145,10 +145,10 @@ async fn list_models_pagination_works() -> Result<()> { ) .await??; - let ListModelsResponse { - items: second_items, + let ModelListResponse { + data: second_items, next_cursor: second_cursor, - } = to_response::(second_response)?; + } = to_response::(second_response)?; assert_eq!(second_items.len(), 1); assert_eq!(second_items[0].id, "gpt-5"); @@ -164,8 +164,8 @@ async fn list_models_rejects_invalid_cursor() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_list_models_request(ListModelsParams { - page_size: None, + .send_list_models_request(ModelListParams { + limit: None, cursor: Some("invalid".to_string()), }) .await?; diff --git a/codex-rs/app-server/tests/suite/rate_limits.rs b/codex-rs/app-server/tests/suite/rate_limits.rs index 16cbf153e9..c6ef7dccea 100644 --- a/codex-rs/app-server/tests/suite/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/rate_limits.rs @@ -7,10 +7,10 @@ use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; use codex_core::auth::AuthCredentialsStoreMode; -use codex_protocol::protocol::RateLimitSnapshot; -use codex_protocol::protocol::RateLimitWindow; use pretty_assertions::assert_eq; use serde_json::json; use std::path::Path; @@ -143,13 +143,13 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { let expected = GetAccountRateLimitsResponse { rate_limits: RateLimitSnapshot { primary: Some(RateLimitWindow { - used_percent: 42.0, - window_minutes: Some(60), + used_percent: 42, + window_duration_mins: Some(60), resets_at: Some(primary_reset_timestamp), }), secondary: Some(RateLimitWindow { - used_percent: 5.0, - window_minutes: Some(1440), + used_percent: 5, + window_duration_mins: Some(1440), resets_at: Some(secondary_reset_timestamp), }), },