diff --git a/AGENTS.md b/AGENTS.md index 81b8d10ac7..de6ef8b9da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ In the codex-rs folder where the rust code lives: - Always collapse if statements per https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_if - Always inline format! args when possible per https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args - Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls +- Do not use unsigned integer even if the number cannot be negative. - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. diff --git a/README.md b/README.md index 3a78f96573..722048b30b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

npm i -g @openai/codex
or brew install codex

+

npm i -g @openai/codex
or brew install --cask codex

Codex CLI is a coding agent from OpenAI that runs locally on your computer.
@@ -24,7 +24,7 @@ npm install -g @openai/codex Alternatively, if you use Homebrew: ```shell -brew install codex +brew install --cask codex ``` Then simply run `codex` to get started: diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0ecada0fb3..c700a2dd1c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -861,9 +861,11 @@ name = "codex-app-server-protocol" version = "0.0.0" dependencies = [ "anyhow", + "clap", "codex-protocol", "paste", "pretty_assertions", + "schemars 0.8.22", "serde", "serde_json", "strum_macros 0.27.2", @@ -1332,6 +1334,7 @@ dependencies = [ "icu_locale_core", "mcp-types", "mime_guess", + "schemars 0.8.22", "serde", "serde_json", "serde_with", @@ -3579,6 +3582,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" name = "mcp-types" version = "0.0.0" dependencies = [ + "schemars 0.8.22", "serde", "serde_json", "ts-rs", diff --git a/codex-rs/README.md b/codex-rs/README.md index 29a4e96b89..e7dfadb789 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -11,7 +11,7 @@ npm i -g @openai/codex codex ``` -You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). +You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). ## Documentation quickstart diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index b18028fbe4..58b0b9d607 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -11,8 +11,11 @@ path = "src/lib.rs" workspace = true [dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } codex-protocol = { workspace = true } paste = { workspace = true } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/app-server-protocol/src/bin/export.rs b/codex-rs/app-server-protocol/src/bin/export.rs new file mode 100644 index 0000000000..d029ecbffa --- /dev/null +++ b/codex-rs/app-server-protocol/src/bin/export.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command( + about = "Generate TypeScript bindings and JSON Schemas for the Codex app-server protocol" +)] +struct Args { + /// Output directory where generated files will be written + #[arg(short = 'o', long = "out", value_name = "DIR")] + out_dir: PathBuf, + + /// Optional Prettier executable path to format generated TypeScript files + #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] + prettier: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + codex_app_server_protocol::generate_types(&args.out_dir, args.prettier.as_deref()) +} diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs new file mode 100644 index 0000000000..21ba3facc4 --- /dev/null +++ b/codex-rs/app-server-protocol/src/export.rs @@ -0,0 +1,404 @@ +use crate::ClientNotification; +use crate::ClientRequest; +use crate::ServerNotification; +use crate::ServerRequest; +use crate::export_client_response_schemas; +use crate::export_client_responses; +use crate::export_server_response_schemas; +use crate::export_server_responses; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use schemars::JsonSchema; +use schemars::schema::RootSchema; +use schemars::schema_for; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fs; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +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::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); + }; +} + +pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + generate_ts(out_dir, prettier)?; + generate_json(out_dir)?; + Ok(()) +} + +pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + ensure_dir(out_dir)?; + + ClientRequest::export_all_to(out_dir)?; + export_client_responses(out_dir)?; + ClientNotification::export_all_to(out_dir)?; + + ServerRequest::export_all_to(out_dir)?; + export_server_responses(out_dir)?; + ServerNotification::export_all_to(out_dir)?; + + generate_index_ts(out_dir)?; + + let ts_files = ts_files_in(out_dir)?; + for file in &ts_files { + prepend_header_if_missing(file)?; + } + + if let Some(prettier_bin) = prettier + && !ts_files.is_empty() + { + let status = Command::new(prettier_bin) + .arg("--write") + .args(ts_files.iter().map(|p| p.as_os_str())) + .status() + .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; + if !status.success() { + return Err(anyhow!("Prettier failed with status {status}")); + } + } + + Ok(()) +} + +pub fn generate_json(out_dir: &Path) -> Result<()> { + ensure_dir(out_dir)?; + 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); + }}; + } + + for_each_schema_type!(add_schema); + + export_client_response_schemas(out_dir)?; + export_server_response_schemas(out_dir)?; + + let mut definitions = Map::new(); + + const SPECIAL_DEFINITIONS: &[&str] = &[ + "ClientNotification", + "ClientRequest", + "EventMsg", + "FileChange", + "InputItem", + "ParsedCommand", + "SandboxPolicy", + "ServerNotification", + "ServerRequest", + ]; + + for (name, schema) in bundle { + let mut schema_value = serde_json::to_value(schema)?; + if let Value::Object(ref mut obj) = schema_value { + if let Some(defs) = obj.remove("definitions") + && let Value::Object(defs_obj) = defs + { + for (def_name, def_schema) in defs_obj { + if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { + definitions.insert(def_name, def_schema); + } + } + } + + if let Some(Value::Array(one_of)) = obj.get_mut("oneOf") { + for variant in one_of.iter_mut() { + if let Some(variant_name) = variant_definition_name(&name, variant) + && let Value::Object(variant_obj) = variant + { + variant_obj.insert("title".into(), Value::String(variant_name)); + } + } + } + } + definitions.insert(name, schema_value); + } + + let mut root = Map::new(); + root.insert( + "$schema".to_string(), + Value::String("http://json-schema.org/draft-07/schema#".into()), + ); + root.insert( + "title".to_string(), + Value::String("CodexAppServerProtocol".into()), + ); + root.insert("type".to_string(), Value::String("object".into())); + root.insert("definitions".to_string(), Value::Object(definitions)); + + write_pretty_json( + out_dir.join("codex_app_server_protocol.schemas.json"), + &Value::Object(root), + )?; + + Ok(()) +} + +fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result +where + T: JsonSchema, +{ + let file_stem = name.trim(); + let schema = schema_for!(T); + write_pretty_json(out_dir.join(format!("{file_stem}.json")), &schema) + .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; + Ok(schema) +} + +pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result<()> +where + T: JsonSchema, +{ + write_json_schema_with_return::(out_dir, name).map(|_| ()) +} + +fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> { + let json = serde_json::to_vec_pretty(value) + .with_context(|| format!("Failed to serialize JSON schema to {}", path.display()))?; + 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() +} + +fn variant_definition_name(base: &str, variant: &Value) -> Option { + if let Some(props) = variant.get("properties").and_then(Value::as_object) { + if let Some(method_literal) = literal_from_property(props, "method") { + let pascal = to_pascal_case(method_literal); + return Some(match base { + "ClientRequest" | "ServerRequest" => format!("{pascal}Request"), + "ClientNotification" | "ServerNotification" => format!("{pascal}Notification"), + _ => format!("{pascal}{base}"), + }); + } + + if let Some(type_literal) = literal_from_property(props, "type") { + let pascal = to_pascal_case(type_literal); + return Some(match base { + "EventMsg" => format!("{pascal}EventMsg"), + _ => format!("{pascal}{base}"), + }); + } + + if let Some(mode_literal) = literal_from_property(props, "mode") { + let pascal = to_pascal_case(mode_literal); + return Some(match base { + "SandboxPolicy" => format!("{pascal}SandboxPolicy"), + _ => format!("{pascal}{base}"), + }); + } + + if props.len() == 1 + && let Some(key) = props.keys().next() + { + let pascal = to_pascal_case(key); + return Some(format!("{pascal}{base}")); + } + } + + if let Some(required) = variant.get("required").and_then(Value::as_array) + && required.len() == 1 + && let Some(key) = required[0].as_str() + { + let pascal = to_pascal_case(key); + return Some(format!("{pascal}{base}")); + } + + None +} + +fn literal_from_property<'a>(props: &'a Map, key: &str) -> Option<&'a str> { + props + .get(key) + .and_then(|value| value.get("enum")) + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .and_then(Value::as_str) +} + +fn to_pascal_case(input: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + for c in input.chars() { + if c == '_' || c == '-' { + capitalize_next = true; + continue; + } + + if capitalize_next { + result.extend(c.to_uppercase()); + capitalize_next = false; + } else { + result.push(c); + } + } + + result +} + +fn ensure_dir(dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .with_context(|| format!("Failed to create output directory {}", dir.display())) +} + +fn prepend_header_if_missing(path: &Path) -> Result<()> { + let mut content = String::new(); + { + let mut f = fs::File::open(path) + .with_context(|| format!("Failed to open {} for reading", path.display()))?; + f.read_to_string(&mut content) + .with_context(|| format!("Failed to read {}", path.display()))?; + } + + if content.starts_with(HEADER) { + return Ok(()); + } + + let mut f = fs::File::create(path) + .with_context(|| format!("Failed to open {} for writing", path.display()))?; + f.write_all(HEADER.as_bytes()) + .with_context(|| format!("Failed to write header to {}", path.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write content to {}", path.display()))?; + Ok(()) +} + +fn ts_files_in(dir: &Path) -> Result> { + let mut files = Vec::new(); + for entry in + fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + 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)? + .into_iter() + .filter_map(|p| { + let stem = p.file_stem()?.to_string_lossy().into_owned(); + if stem == "index" { None } else { Some(stem) } + }) + .collect(); + stems.sort(); + stems.dedup(); + + for name in stems { + entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); + } + + let mut content = + String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); + content.push_str(HEADER); + for line in &entries { + content.push_str(line); + } + + let index_path = out_dir.join("index.ts"); + let mut f = fs::File::create(&index_path) + .with_context(|| format!("Failed to create {}", index_path.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write {}", index_path.display()))?; + Ok(index_path) +} diff --git a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs index 9d6d6da06f..c0b88b0215 100644 --- a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs +++ b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs @@ -1,13 +1,14 @@ //! We do not do true JSON-RPC 2.0, as we neither send nor expect the //! "jsonrpc": "2.0" field. +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; pub const JSONRPC_VERSION: &str = "2.0"; -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] #[serde(untagged)] pub enum RequestId { String(String), @@ -18,7 +19,7 @@ pub enum RequestId { pub type Result = serde_json::Value; /// Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum JSONRPCMessage { Request(JSONRPCRequest), @@ -28,7 +29,7 @@ pub enum JSONRPCMessage { } /// A request that expects a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCRequest { pub id: RequestId, pub method: String, @@ -37,7 +38,7 @@ pub struct JSONRPCRequest { } /// A notification which does not expect a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCNotification { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -45,20 +46,20 @@ pub struct JSONRPCNotification { } /// A successful (non-error) response to a request. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCResponse { pub id: RequestId, pub result: Result, } /// A response to a request that indicates an error occurred. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCError { pub error: JSONRPCErrorError, pub id: RequestId, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCErrorError { pub code: i64, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 517702bbd8..cc2310bc85 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -1,5 +1,9 @@ +mod export; mod jsonrpc_lite; mod protocol; +pub use export::generate_json; +pub use export::generate_ts; +pub use export::generate_types; pub use jsonrpc_lite::*; pub use protocol::*; diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 63b11829cd..9c10d23360 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -18,13 +18,14 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnAbortReason; use paste::paste; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[ts(type = "string")] pub struct GitSha(pub String); @@ -34,7 +35,7 @@ impl GitSha { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum AuthMode { ApiKey, @@ -56,7 +57,7 @@ macro_rules! client_request_definitions { ),* $(,)? ) => { /// Request from the client to the server. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientRequest { $( @@ -78,6 +79,15 @@ macro_rules! client_request_definitions { )* Ok(()) } + + pub fn export_client_response_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + $( + crate::export::write_json_schema::<$response>(out_dir, stringify!($response))?; + )* + Ok(()) + } }; } @@ -175,13 +185,13 @@ client_request_definitions! { }, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeParams { pub client_info: ClientInfo, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ClientInfo { pub name: String, @@ -190,13 +200,13 @@ pub struct ClientInfo { pub version: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeResponse { pub user_agent: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationParams { /// Optional override for the model name (e.g. "o3", "o4-mini"). @@ -239,7 +249,7 @@ pub struct NewConversationParams { pub include_apply_patch_tool: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationResponse { pub conversation_id: ConversationId, @@ -250,7 +260,7 @@ pub struct NewConversationResponse { pub rollout_path: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeConversationResponse { pub conversation_id: ConversationId, @@ -259,7 +269,7 @@ pub struct ResumeConversationResponse { pub initial_messages: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ListConversationsParams { /// Optional page size; defaults to a reasonable server-side value. @@ -270,7 +280,7 @@ pub struct ListConversationsParams { pub cursor: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ConversationSummary { pub conversation_id: ConversationId, @@ -281,7 +291,7 @@ pub struct ConversationSummary { pub timestamp: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ListConversationsResponse { pub items: Vec, @@ -291,7 +301,7 @@ pub struct ListConversationsResponse { pub next_cursor: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeConversationParams { /// Absolute path to the rollout JSONL file. @@ -301,78 +311,81 @@ pub struct ResumeConversationParams { pub overrides: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AddConversationSubscriptionResponse { + #[schemars(with = "String")] pub subscription_id: Uuid, } /// The [`ConversationId`] must match the `rollout_path`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ArchiveConversationParams { pub conversation_id: ConversationId, pub rollout_path: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ArchiveConversationResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct RemoveConversationSubscriptionResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginApiKeyParams { pub api_key: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginApiKeyResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptResponse { + #[schemars(with = "String")] pub login_id: Uuid, /// URL the client should open in a browser to initiate the OAuth flow. pub auth_url: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GitDiffToRemoteResponse { pub sha: GitSha, pub diff: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptParams { + #[schemars(with = "String")] pub login_id: Uuid, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GitDiffToRemoteParams { pub cwd: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LogoutChatGptParams {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LogoutChatGptResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusParams { /// If true, include the current auth token (if available) in the response. @@ -383,7 +396,7 @@ pub struct GetAuthStatusParams { pub refresh_token: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecOneOffCommandParams { /// Command argv to execute. @@ -399,7 +412,7 @@ pub struct ExecOneOffCommandParams { pub sandbox_policy: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecOneOffCommandResponse { pub exit_code: i32, @@ -407,7 +420,7 @@ pub struct ExecOneOffCommandResponse { pub stderr: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -422,13 +435,13 @@ pub struct GetAuthStatusResponse { pub requires_openai_auth: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetUserAgentResponse { pub user_agent: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UserInfoResponse { /// Note: `alleged_user_email` is not currently verified. We read it from @@ -438,13 +451,13 @@ pub struct UserInfoResponse { pub alleged_user_email: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetUserSavedConfigResponse { pub config: UserSavedConfig, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SetDefaultModelParams { /// If set to None, this means `model` should be cleared in config.toml. @@ -456,14 +469,14 @@ pub struct SetDefaultModelParams { pub reasoning_effort: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SetDefaultModelResponse {} /// UserSavedConfig contains a subset of the config. It is meant to expose mcp /// client-configurable settings that can be specified in the NewConversation /// and SendUserTurn requests. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UserSavedConfig { /// Approvals @@ -501,7 +514,7 @@ pub struct UserSavedConfig { } /// MCP representation of a [`codex_core::config_profile::ConfigProfile`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct Profile { pub model: Option, @@ -515,7 +528,7 @@ pub struct Profile { pub chatgpt_base_url: Option, } /// MCP representation of a [`codex_core::config::ToolsToml`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct Tools { #[serde(skip_serializing_if = "Option::is_none")] @@ -525,7 +538,7 @@ pub struct Tools { } /// MCP representation of a [`codex_core::config_types::SandboxWorkspaceWrite`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SandboxSettings { #[serde(default)] @@ -538,14 +551,14 @@ pub struct SandboxSettings { pub exclude_slash_tmp: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageParams { pub conversation_id: ConversationId, pub items: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnParams { pub conversation_id: ConversationId, @@ -559,39 +572,40 @@ pub struct SendUserTurnParams { pub summary: ReasoningSummary, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationParams { pub conversation_id: ConversationId, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationResponse { pub abort_reason: TurnAbortReason, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AddConversationListenerParams { pub conversation_id: ConversationId, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct RemoveConversationListenerParams { + #[schemars(with = "String")] pub subscription_id: Uuid, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[serde(tag = "type", content = "data")] pub enum InputItem { @@ -623,7 +637,7 @@ macro_rules! server_request_definitions { ) => { paste! { /// Request initiated from the server and sent to the client. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerRequest { $( @@ -636,7 +650,7 @@ macro_rules! server_request_definitions { )* } - #[derive(Debug, Clone, PartialEq)] + #[derive(Debug, Clone, PartialEq, JsonSchema)] pub enum ServerRequestPayload { $( $variant([<$variant Params>]), )* } @@ -658,6 +672,15 @@ macro_rules! server_request_definitions { } Ok(()) } + + pub fn export_server_response_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + paste! { + $(crate::export::write_json_schema::<[<$variant Response>]>(out_dir, stringify!([<$variant Response>]))?;)* + } + Ok(()) + } }; } @@ -676,7 +699,7 @@ server_request_definitions! { ExecCommandApproval, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ApplyPatchApprovalParams { pub conversation_id: ConversationId, @@ -693,7 +716,7 @@ pub struct ApplyPatchApprovalParams { pub grant_root: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecCommandApprovalParams { pub conversation_id: ConversationId, @@ -707,17 +730,17 @@ pub struct ExecCommandApprovalParams { pub parsed_cmd: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ExecCommandApprovalResponse { pub decision: ReviewDecision, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ApplyPatchApprovalResponse { pub decision: ReviewDecision, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] pub struct FuzzyFileSearchParams { @@ -729,7 +752,7 @@ pub struct FuzzyFileSearchParams { } /// Superset of [`codex_file_search::FileMatch`] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FuzzyFileSearchResult { pub root: String, pub path: String, @@ -739,21 +762,22 @@ pub struct FuzzyFileSearchResult { pub indices: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FuzzyFileSearchResponse { pub files: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptCompleteNotification { + #[schemars(with = "String")] pub login_id: Uuid, pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SessionConfiguredNotification { /// Name left as session_id instead of conversation_id for backwards compatibility. @@ -781,7 +805,7 @@ pub struct SessionConfiguredNotification { pub rollout_path: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AuthStatusChangeNotification { /// Current authentication method; omitted if signed out. @@ -790,7 +814,7 @@ pub struct AuthStatusChangeNotification { } /// Notification sent from the server to the client. -#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ServerNotification { @@ -823,7 +847,7 @@ impl TryFrom for ServerNotification { } /// Notification sent from the client to the server. -#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ClientNotification { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f8891f4a80..663db04ceb 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -126,10 +126,12 @@ impl ModelClient { } } - pub fn get_model_context_window(&self) -> Option { + pub fn get_model_context_window(&self) -> Option { + let pct = self.config.model_family.effective_context_window_percent; self.config .model_context_window .or_else(|| get_model_info(&self.config.model_family).map(|info| info.context_window)) + .map(|w| w.saturating_mul(pct) / 100) } pub fn get_auto_compact_token_limit(&self) -> Option { @@ -554,11 +556,11 @@ struct ResponseCompleted { #[derive(Debug, Deserialize)] struct ResponseCompletedUsage { - input_tokens: u64, + input_tokens: i64, input_tokens_details: Option, - output_tokens: u64, + output_tokens: i64, output_tokens_details: Option, - total_tokens: u64, + total_tokens: i64, } impl From for TokenUsage { @@ -581,12 +583,12 @@ impl From for TokenUsage { #[derive(Debug, Deserialize)] struct ResponseCompletedInputTokensDetails { - cached_tokens: u64, + cached_tokens: i64, } #[derive(Debug, Deserialize)] struct ResponseCompletedOutputTokensDetails { - reasoning_tokens: u64, + reasoning_tokens: i64, } fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { @@ -643,7 +645,7 @@ fn parse_rate_limit_window( let used_percent: Option = parse_header_f64(headers, used_percent_header); used_percent.and_then(|used_percent| { - let window_minutes = parse_header_u64(headers, window_minutes_header); + let window_minutes = parse_header_i64(headers, window_minutes_header); let resets_at = parse_header_str(headers, resets_header) .map(str::trim) .filter(|value| !value.is_empty()) @@ -674,8 +676,8 @@ fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { .filter(|v| v.is_finite()) } -fn parse_header_u64(headers: &HeaderMap, name: &str) -> Option { - parse_header_str(headers, name)?.parse::().ok() +fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)?.parse::().ok() } fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2ce0997075..b7554f0e14 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1778,7 +1778,7 @@ pub(crate) async fn run_task( .as_ref() .map(TokenUsage::tokens_in_context_window); let token_limit_reached = total_usage_tokens - .map(|tokens| (tokens as i64) >= limit) + .map(|tokens| tokens >= limit) .unwrap_or(false); let mut items_to_record_in_conversation_history = Vec::::new(); let mut responses = Vec::::new(); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 0283e360d4..93d0ad4ba0 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -85,10 +85,10 @@ pub struct Config { pub model_family: ModelFamily, /// Size of the context window for the model, in tokens. - pub model_context_window: Option, + pub model_context_window: Option, /// Maximum number of output tokens. - pub model_max_output_tokens: Option, + pub model_max_output_tokens: Option, /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, @@ -824,10 +824,10 @@ pub struct ConfigToml { pub model_provider: Option, /// Size of the context window for the model, in tokens. - pub model_context_window: Option, + pub model_context_window: Option, /// Maximum number of output tokens. - pub model_max_output_tokens: Option, + pub model_max_output_tokens: Option, /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, @@ -2805,7 +2805,7 @@ model_verbosity = "high" model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), model_max_output_tokens: Some(100_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, @@ -2874,7 +2874,7 @@ model_verbosity = "high" model_family: find_family_for_model("gpt-3.5-turbo").expect("known model slug"), model_context_window: Some(16_385), model_max_output_tokens: Some(4_096), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(14_746), model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessTrusted, @@ -2958,7 +2958,7 @@ model_verbosity = "high" model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), model_max_output_tokens: Some(100_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, @@ -3028,7 +3028,7 @@ model_verbosity = "high" model_family: find_family_for_model("gpt-5").expect("known model slug"), model_context_window: Some(272_000), model_max_output_tokens: Some(128_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(244_800), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 80b3f27986..623cc26ef9 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -48,6 +48,12 @@ pub struct ModelFamily { /// Names of beta tools that should be exposed to this model family. pub experimental_supported_tools: Vec, + + /// Percentage of the context window considered usable for inputs, after + /// reserving headroom for system prompts, tool overhead, and model output. + /// This is applied when computing the effective context window seen by + /// consumers. + pub effective_context_window_percent: i64, } macro_rules! model_family { @@ -66,6 +72,7 @@ macro_rules! model_family { apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, }; // apply overrides $( @@ -175,5 +182,6 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily { apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, } } diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index d1d2305aaa..e0d0bfd1b2 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -1,5 +1,9 @@ use crate::model_family::ModelFamily; +// Shared constants for commonly used window/token sizes. +pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; +pub(crate) const MAX_OUTPUT_TOKENS_128K: i64 = 128_000; + /// Metadata about a model, particularly OpenAI models. /// We may want to consider including details like the pricing for /// input tokens, output tokens, etc., though users will need to be able to @@ -8,10 +12,10 @@ use crate::model_family::ModelFamily; #[derive(Debug)] pub(crate) struct ModelInfo { /// Size of the context window in tokens. This is the maximum size of the input context. - pub(crate) context_window: u64, + pub(crate) context_window: i64, /// Maximum number of output tokens that can be generated for the model. - pub(crate) max_output_tokens: u64, + pub(crate) max_output_tokens: i64, /// Token threshold where we should automatically compact conversation history. This considers /// input tokens + output tokens of this turn. @@ -19,13 +23,17 @@ pub(crate) struct ModelInfo { } impl ModelInfo { - const fn new(context_window: u64, max_output_tokens: u64) -> Self { + const fn new(context_window: i64, max_output_tokens: i64) -> Self { Self { context_window, max_output_tokens, - auto_compact_token_limit: None, + auto_compact_token_limit: Some(Self::default_auto_compact_limit(context_window)), } } + + const fn default_auto_compact_limit(context_window: i64) -> i64 { + (context_window * 9) / 10 + } } pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { @@ -62,15 +70,17 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { // https://platform.openai.com/docs/models/gpt-3.5-turbo "gpt-3.5-turbo" => Some(ModelInfo::new(16_385, 4_096)), - _ if slug.starts_with("gpt-5-codex") => Some(ModelInfo { - context_window: 272_000, - max_output_tokens: 128_000, - auto_compact_token_limit: Some(350_000), - }), + _ if slug.starts_with("gpt-5-codex") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } - _ if slug.starts_with("gpt-5") => Some(ModelInfo::new(272_000, 128_000)), + _ if slug.starts_with("gpt-5") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } - _ if slug.starts_with("codex-") => Some(ModelInfo::new(272_000, 128_000)), + _ if slug.starts_with("codex-") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } _ => None, } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index fa7543ff53..eaa3bcb92a 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -48,7 +48,7 @@ impl SessionState { pub(crate) fn update_token_info_from_usage( &mut self, usage: &TokenUsage, - model_context_window: Option, + model_context_window: Option, ) { self.token_info = TokenUsageInfo::new_or_append( &self.token_info, @@ -67,7 +67,7 @@ impl SessionState { (self.token_info.clone(), self.latest_rate_limits.clone()) } - pub(crate) fn set_token_usage_full(&mut self, context_window: u64) { + pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { match &mut self.token_info { Some(info) => info.fill_to_context_window(context_window), None => { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 98b3eca1f4..a8a777ae4f 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -138,7 +138,7 @@ pub fn ev_response_created(id: &str) -> Value { }) } -pub fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { +pub fn ev_completed_with_tokens(id: &str, total_tokens: i64) -> Value { serde_json::json!({ "type": "response.completed", "response": { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs new file mode 100644 index 0000000000..0ce1183edd --- /dev/null +++ b/codex-rs/core/tests/suite/approvals.rs @@ -0,0 +1,1175 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::model_family::find_family_for_model; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::ReviewDecision; +use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[derive(Clone, Copy)] +enum TargetPath { + Workspace(&'static str), + OutsideWorkspace(&'static str), +} + +impl TargetPath { + fn resolve_for_patch(self, test: &TestCodex) -> (PathBuf, String) { + match self { + TargetPath::Workspace(name) => { + let path = test.cwd.path().join(name); + (path, name.to_string()) + } + TargetPath::OutsideWorkspace(name) => { + let path = env::current_dir() + .expect("current dir should be available") + .join(name); + (path.clone(), path.display().to_string()) + } + } + } +} + +#[derive(Clone)] +enum ActionKind { + WriteFile { + target: TargetPath, + content: &'static str, + }, + FetchUrl { + endpoint: &'static str, + response_body: &'static str, + }, + RunCommand { + command: &'static [&'static str], + }, + ApplyPatchFunction { + target: TargetPath, + content: &'static str, + }, + ApplyPatchShell { + target: TargetPath, + content: &'static str, + }, +} + +impl ActionKind { + async fn prepare( + &self, + test: &TestCodex, + server: &MockServer, + call_id: &str, + with_escalated_permissions: bool, + ) -> Result<(Value, Option>)> { + match self { + ActionKind::WriteFile { target, content } => { + let (path, _) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!( + "printf {content:?} > {path:?} && cat {path:?}", + content = content, + path = path + ), + ]; + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::FetchUrl { + endpoint, + response_body, + } => { + Mock::given(method("GET")) + .and(path(*endpoint)) + .respond_with( + ResponseTemplate::new(200).set_body_string(response_body.to_string()), + ) + .mount(server) + .await; + + let url = format!("{}{}", server.uri(), endpoint); + let script = format!( + "import sys\nimport urllib.request\nurl = {url:?}\ntry:\n data = urllib.request.urlopen(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)", + ); + + let command = vec!["python3".to_string(), "-c".to_string(), script]; + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::RunCommand { command } => { + let command: Vec = command + .iter() + .map(std::string::ToString::to_string) + .collect(); + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::ApplyPatchFunction { target, content } => { + let (path, patch_path) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let patch = build_add_file_patch(&patch_path, content); + Ok((ev_apply_patch_function_call(call_id, &patch), None)) + } + ActionKind::ApplyPatchShell { target, content } => { + let (path, patch_path) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let patch = build_add_file_patch(&patch_path, content); + let command = shell_apply_patch_command(&patch); + let event = shell_event(call_id, &command, 5_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + } + } +} + +fn build_add_file_patch(patch_path: &str, content: &str) -> String { + format!("*** Begin Patch\n*** Add File: {patch_path}\n+{content}\n*** End Patch\n") +} + +fn shell_apply_patch_command(patch: &str) -> Vec { + let mut script = String::from("apply_patch <<'PATCH'\n"); + script.push_str(patch); + if !patch.ends_with('\n') { + script.push('\n'); + } + script.push_str("PATCH\n"); + vec!["bash".to_string(), "-lc".to_string(), script] +} + +fn shell_event( + call_id: &str, + command: &[String], + timeout_ms: u64, + with_escalated_permissions: bool, +) -> Result { + let mut args = json!({ + "command": command, + "timeout_ms": timeout_ms, + }); + if with_escalated_permissions { + args["with_escalated_permissions"] = json!(true); + } + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "shell", &args_str)) +} + +#[derive(Clone)] +enum Expectation { + FileCreated { + target: TargetPath, + content: &'static str, + }, + PatchApplied { + target: TargetPath, + content: &'static str, + }, + FileNotCreated { + target: TargetPath, + message_contains: &'static [&'static str], + }, + NetworkSuccess { + body_contains: &'static str, + }, + NetworkFailure { + expect_tag: &'static str, + }, + CommandSuccess { + stdout_contains: &'static str, + }, +} + +impl Expectation { + fn verify(&self, test: &TestCodex, result: &CommandResult) -> Result<()> { + match self { + Expectation::FileCreated { target, content } => { + let (path, _) = target.resolve_for_patch(test); + assert_eq!( + result.exit_code, + Some(0), + "expected successful exit for {:?}", + path + ); + assert!( + result.stdout.contains(content), + "stdout missing {content:?}: {}", + result.stdout + ); + let file_contents = fs::read_to_string(&path)?; + assert!( + file_contents.contains(content), + "file contents missing {content:?}: {file_contents}" + ); + let _ = fs::remove_file(path); + } + Expectation::PatchApplied { target, content } => { + let (path, _) = target.resolve_for_patch(test); + match result.exit_code { + Some(0) | None => { + if result.exit_code.is_none() { + assert!( + result.stdout.contains("Success."), + "patch output missing success indicator: {}", + result.stdout + ); + } + } + Some(code) => panic!( + "expected successful patch exit for {:?}, got {code} with stdout {}", + path, result.stdout + ), + } + let file_contents = fs::read_to_string(&path)?; + assert!( + file_contents.contains(content), + "patched file missing {content:?}: {file_contents}" + ); + let _ = fs::remove_file(path); + } + Expectation::FileNotCreated { + target, + message_contains, + } => { + let (path, _) = target.resolve_for_patch(test); + assert_ne!( + result.exit_code, + Some(0), + "expected non-zero exit for {path:?}" + ); + for needle in *message_contains { + assert!( + result.stdout.contains(needle), + "stdout missing {needle:?}: {}", + result.stdout + ); + } + assert!( + !path.exists(), + "command should not create {path:?}, but file exists" + ); + } + Expectation::NetworkSuccess { body_contains } => { + assert_eq!( + result.exit_code, + Some(0), + "expected successful network exit: {}", + result.stdout + ); + assert!( + result.stdout.contains("OK:"), + "stdout missing OK prefix: {}", + result.stdout + ); + assert!( + result.stdout.contains(body_contains), + "stdout missing body text {body_contains:?}: {}", + result.stdout + ); + } + Expectation::NetworkFailure { expect_tag } => { + assert_ne!( + result.exit_code, + Some(0), + "expected non-zero exit for network failure: {}", + result.stdout + ); + assert!( + result.stdout.contains("ERR:"), + "stdout missing ERR prefix: {}", + result.stdout + ); + assert!( + result.stdout.contains(expect_tag), + "stdout missing expected tag {expect_tag:?}: {}", + result.stdout + ); + } + Expectation::CommandSuccess { stdout_contains } => { + assert_eq!( + result.exit_code, + Some(0), + "expected successful trusted command exit: {}", + result.stdout + ); + assert!( + result.stdout.contains(stdout_contains), + "trusted command stdout missing {stdout_contains:?}: {}", + result.stdout + ); + } + } + Ok(()) + } +} + +#[derive(Clone)] +enum Outcome { + Auto, + ExecApproval { + decision: ReviewDecision, + expected_reason: Option<&'static str>, + }, + PatchApproval { + decision: ReviewDecision, + expected_reason: Option<&'static str>, + }, +} + +#[derive(Clone)] +struct ScenarioSpec { + name: &'static str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + action: ActionKind, + with_escalated_permissions: bool, + requires_apply_patch_tool: bool, + model_override: Option<&'static str>, + outcome: Outcome, + expectation: Expectation, +} + +struct CommandResult { + exit_code: Option, + stdout: String, +} + +async fn submit_turn( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, +) -> Result<()> { + let session_model = test.session_configured.model.clone(); + + test.codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: prompt.into(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy, + sandbox_policy, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + Ok(()) +} + +fn parse_result(item: &Value) -> CommandResult { + let output_str = item + .get("output") + .and_then(Value::as_str) + .expect("shell output payload"); + match serde_json::from_str::(output_str) { + Ok(parsed) => { + let exit_code = parsed["metadata"]["exit_code"].as_i64(); + let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); + CommandResult { exit_code, stdout } + } + Err(_) => CommandResult { + exit_code: None, + stdout: output_str.to_string(), + }, + } +} + +async fn expect_exec_approval( + test: &TestCodex, + expected_command: &[String], +) -> ExecApprovalRequestEvent { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::ExecApprovalRequest(approval) => { + assert_eq!(approval.command, expected_command); + approval + } + EventMsg::TaskComplete(_) => panic!("expected approval request before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +async fn expect_patch_approval( + test: &TestCodex, + expected_call_id: &str, +) -> ApplyPatchApprovalRequestEvent { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::ApplyPatchApprovalRequest(approval) => { + assert_eq!(approval.call_id, expected_call_id); + approval + } + EventMsg::TaskComplete(_) => panic!("expected patch approval request before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion_without_approval(test: &TestCodex) { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::TaskComplete(_) => {} + EventMsg::ExecApprovalRequest(event) => { + panic!("unexpected approval request: {:?}", event.command) + } + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion(test: &TestCodex) { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TaskComplete(_)) + }) + .await; +} + +fn scenarios() -> Vec { + use AskForApproval::*; + + let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + vec![ + ScenarioSpec { + name: "danger_full_access_on_request_allows_outside_write", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), + content: "danger-on-request", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), + content: "danger-on-request", + }, + }, + ScenarioSpec { + name: "danger_full_access_on_request_allows_network", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::FetchUrl { + endpoint: "/dfa/network", + response_body: "danger-network-ok", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkSuccess { + body_contains: "danger-network-ok", + }, + }, + ScenarioSpec { + name: "trusted_command_unless_trusted_runs_without_prompt", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::RunCommand { + command: &["echo", "trusted-unless"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-unless", + }, + }, + ScenarioSpec { + name: "danger_full_access_on_failure_allows_outside_write", + approval_policy: OnFailure, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), + content: "danger-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), + content: "danger-on-failure", + }, + }, + ScenarioSpec { + name: "danger_full_access_unless_trusted_requests_approval", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), + content: "danger-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), + content: "danger-unless-trusted", + }, + }, + ScenarioSpec { + name: "danger_full_access_never_allows_outside_write", + approval_policy: Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_never.txt"), + content: "danger-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_never.txt"), + content: "danger-never", + }, + }, + ScenarioSpec { + name: "read_only_on_request_requires_approval", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_request.txt"), + content: "read-only-approval", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_on_request.txt"), + content: "read-only-approval", + }, + }, + ScenarioSpec { + name: "trusted_command_on_request_read_only_runs_without_prompt", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::RunCommand { + command: &["echo", "trusted-read-only"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-read-only", + }, + }, + ScenarioSpec { + name: "read_only_on_request_blocks_network", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::FetchUrl { + endpoint: "/ro/network-blocked", + response_body: "should-not-see", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, + }, + ScenarioSpec { + name: "read_only_on_request_denied_blocks_execution", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_request_denied.txt"), + content: "should-not-write", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::FileNotCreated { + target: TargetPath::Workspace("ro_on_request_denied.txt"), + message_contains: &["exec command rejected by user"], + }, + }, + #[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently + ScenarioSpec { + name: "read_only_on_failure_escalates_after_sandbox_error", + approval_policy: OnFailure, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_failure.txt"), + content: "read-only-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: Some("command failed; retry without sandbox?"), + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_on_failure.txt"), + content: "read-only-on-failure", + }, + }, + ScenarioSpec { + name: "read_only_on_request_network_escalates_when_approved", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::FetchUrl { + endpoint: "/ro/network-approved", + response_body: "read-only-network-ok", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::NetworkSuccess { + body_contains: "read-only-network-ok", + }, + }, + ScenarioSpec { + name: "apply_patch_shell_requires_patch_approval", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchShell { + target: TargetPath::Workspace("apply_patch_shell.txt"), + content: "shell-apply-patch", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: None, + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_shell.txt"), + content: "shell-apply-patch", + }, + }, + ScenarioSpec { + name: "apply_patch_function_auto_inside_workspace", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::ApplyPatchFunction { + target: TargetPath::Workspace("apply_patch_function.txt"), + content: "function-apply-patch", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_function.txt"), + content: "function-apply-patch", + }, + }, + ScenarioSpec { + name: "apply_patch_function_danger_allows_outside_workspace", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), + content: "function-patch-danger", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), + content: "function-patch-danger", + }, + }, + ScenarioSpec { + name: "apply_patch_function_outside_requires_patch_approval", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), + content: "function-patch-outside", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), + content: "function-patch-outside", + }, + }, + ScenarioSpec { + name: "apply_patch_function_outside_denied_blocks_patch", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + content: "function-patch-outside-denied", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + message_contains: &["patch rejected by user"], + }, + }, + ScenarioSpec { + name: "apply_patch_shell_outside_requires_patch_approval", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchShell { + target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), + content: "shell-patch-outside", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: None, + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), + content: "shell-patch-outside", + }, + }, + ScenarioSpec { + name: "apply_patch_function_unless_trusted_requires_patch_approval", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), + content: "function-patch-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), + content: "function-patch-unless-trusted", + }, + }, + ScenarioSpec { + name: "apply_patch_function_never_rejects_outside_workspace", + approval_policy: Never, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + content: "function-patch-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + message_contains: &[ + "patch rejected: writing outside of the project; rejected by user approval settings", + ], + }, + }, + ScenarioSpec { + name: "read_only_unless_trusted_requires_approval", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_unless_trusted.txt"), + content: "read-only-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_unless_trusted.txt"), + content: "read-only-unless-trusted", + }, + }, + ScenarioSpec { + name: "read_only_never_reports_sandbox_failure", + approval_policy: Never, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_never.txt"), + content: "read-only-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::Workspace("ro_never.txt"), + message_contains: if cfg!(target_os = "linux") { + &["Permission denied"] + } else { + &["failed in sandbox"] + }, + }, + }, + ScenarioSpec { + name: "trusted_command_never_runs_without_prompt", + approval_policy: Never, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::RunCommand { + command: &["echo", "trusted-never"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-never", + }, + }, + ScenarioSpec { + name: "workspace_write_on_request_allows_workspace_write", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ww_on_request.txt"), + content: "workspace-on-request", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ww_on_request.txt"), + content: "workspace-on-request", + }, + }, + ScenarioSpec { + name: "workspace_write_network_disabled_blocks_network", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::FetchUrl { + endpoint: "/ww/network-blocked", + response_body: "workspace-network-blocked", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, + }, + ScenarioSpec { + name: "workspace_write_on_request_requires_approval_outside_workspace", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), + content: "workspace-on-request-outside", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), + content: "workspace-on-request-outside", + }, + }, + ScenarioSpec { + name: "workspace_write_network_enabled_allows_network", + approval_policy: OnRequest, + sandbox_policy: workspace_write(true), + action: ActionKind::FetchUrl { + endpoint: "/ww/network-ok", + response_body: "workspace-network-ok", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkSuccess { + body_contains: "workspace-network-ok", + }, + }, + #[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently + ScenarioSpec { + name: "workspace_write_on_failure_escalates_outside_workspace", + approval_policy: OnFailure, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), + content: "workspace-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: Some("command failed; retry without sandbox?"), + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), + content: "workspace-on-failure", + }, + }, + ScenarioSpec { + name: "workspace_write_unless_trusted_requires_approval_outside_workspace", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), + content: "workspace-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), + content: "workspace-unless-trusted", + }, + }, + ScenarioSpec { + name: "workspace_write_never_blocks_outside_workspace", + approval_policy: Never, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_never.txt"), + content: "workspace-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("ww_never.txt"), + message_contains: if cfg!(target_os = "linux") { + &["Permission denied"] + } else { + &["failed in sandbox"] + }, + }, + }, + ] +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approval_matrix_covers_all_modes() -> Result<()> { + skip_if_no_network!(Ok(())); + + for scenario in scenarios() { + run_scenario(&scenario).await?; + } + + Ok(()) +} + +async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { + eprintln!("running approval scenario: {}", scenario.name); + let server = start_mock_server().await; + let approval_policy = scenario.approval_policy; + let sandbox_policy = scenario.sandbox_policy.clone(); + let requires_apply_patch_tool = scenario.requires_apply_patch_tool; + let model_override = scenario.model_override; + + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = approval_policy; + config.sandbox_policy = sandbox_policy.clone(); + let model = model_override.unwrap_or("gpt-5"); + config.model = model.to_string(); + config.model_family = + find_family_for_model(model).expect("model should map to a known family"); + if requires_apply_patch_tool { + config.include_apply_patch_tool = true; + } + }); + let test = builder.build(&server).await?; + + let call_id = scenario.name; + let (event, expected_command) = scenario + .action + .prepare(&test, &server, call_id, scenario.with_escalated_permissions) + .await?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + event, + ev_completed("resp-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + submit_turn( + &test, + scenario.name, + scenario.approval_policy, + scenario.sandbox_policy.clone(), + ) + .await?; + + match &scenario.outcome { + Outcome::Auto => { + wait_for_completion_without_approval(&test).await; + } + Outcome::ExecApproval { + decision, + expected_reason, + } => { + let command = expected_command + .as_ref() + .expect("exec approval requires shell command"); + let approval = expect_exec_approval(&test, command).await; + if let Some(expected_reason) = expected_reason { + assert_eq!( + approval.reason.as_deref(), + Some(*expected_reason), + "unexpected approval reason for {}", + scenario.name + ); + } + test.codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: *decision, + }) + .await?; + wait_for_completion(&test).await; + } + Outcome::PatchApproval { + decision, + expected_reason, + } => { + let approval = expect_patch_approval(&test, call_id).await; + if let Some(expected_reason) = expected_reason { + assert_eq!( + approval.reason.as_deref(), + Some(*expected_reason), + "unexpected patch approval reason for {}", + scenario.name + ); + } + test.codex + .submit(Op::PatchApproval { + id: "0".into(), + decision: *decision, + }) + .await?; + wait_for_completion(&test).await; + } + } + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + scenario.expectation.verify(&test, &result)?; + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 692e1282c2..ed0dbad6dd 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -858,8 +858,8 @@ async fn token_count_includes_rate_limits_snapshot() { "reasoning_output_tokens": 0, "total_tokens": 123 }, - // Default model is gpt-5-codex in tests → 272000 context window - "model_context_window": 272000 + // Default model is gpt-5-codex in tests → 95% usable context window + "model_context_window": 258400 }, "rate_limits": { "primary": { @@ -985,6 +985,8 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res skip_if_no_network!(Ok(())); let server = MockServer::start().await; + const EFFECTIVE_CONTEXT_WINDOW: i64 = (272_000 * 95) / 100; + responses::mount_sse_once_match( &server, body_string_contains("trigger context window"), @@ -1056,8 +1058,11 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res .info .expect("token usage info present when context window is exceeded"); - assert_eq!(info.model_context_window, Some(272_000)); - assert_eq!(info.total_token_usage.total_tokens, 272_000); + assert_eq!(info.model_context_window, Some(EFFECTIVE_CONTEXT_WINDOW)); + assert_eq!( + info.total_token_usage.total_tokens, + EFFECTIVE_CONTEXT_WINDOW + ); let error_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await; let expected_context_window_message = CodexErr::ContextWindowExceeded.to_string(); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 82750e8503..bd760b8749 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -19,6 +19,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; +use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -43,6 +44,7 @@ const CONTEXT_LIMIT_MESSAGE: &str = "Your input exceeds the context window of this model. Please adjust your input and try again."; const DUMMY_FUNCTION_NAME: &str = "unsupported_tool"; const DUMMY_CALL_ID: &str = "call-multi-auto"; +const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() { @@ -860,3 +862,97 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "second auto compact request should include the summarization prompt" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let context_window = 100; + let limit = context_window * 90 / 100; + let over_limit_tokens = context_window * 95 / 100 + 1; + + let first_turn = sse(vec![ + ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + ev_completed_with_tokens("r1", 50), + ]); + let function_call_follow_up = sse(vec![ + ev_assistant_message("m2", FINAL_REPLY), + ev_completed_with_tokens("r2", over_limit_tokens), + ]); + let auto_compact_turn = sse(vec![ + ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r3", 10), + ]); + let post_auto_compact_turn = sse(vec![ev_completed_with_tokens("r4", 10)]); + + // Mount responses in order and keep mocks only for the ones we assert on. + let first_turn_mock = mount_sse_once(&server, first_turn).await; + let follow_up_mock = mount_sse_once(&server, function_call_follow_up).await; + let auto_compact_mock = mount_sse_once(&server, auto_compact_turn).await; + // We don't assert on the post-compact request, so no need to keep its mock. + mount_sse_once(&server, post_auto_compact_turn).await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = model_provider; + config.model_context_window = Some(context_window); + config.model_auto_compact_token_limit = Some(limit); + + let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) + .new_conversation(config) + .await + .unwrap() + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: FUNCTION_CALL_LIMIT_MSG.into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + + // Assert first request captured expected user message that triggers function call. + let first_request = first_turn_mock.single_request().input(); + assert!( + first_request.iter().any(|item| { + item.get("type").and_then(|value| value.as_str()) == Some("message") + && item + .get("content") + .and_then(|content| content.as_array()) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("text")) + .and_then(|value| value.as_str()) + == Some(FUNCTION_CALL_LIMIT_MSG) + }), + "first request should include the user message that triggers the function call" + ); + + let function_call_output = follow_up_mock + .single_request() + .function_call_output(DUMMY_CALL_ID); + let output_text = function_call_output + .get("output") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + assert!( + output_text.contains(DUMMY_FUNCTION_NAME), + "function call output should be sent before auto compact" + ); + + let auto_compact_body = auto_compact_mock.single_request().body_json().to_string(); + assert!( + auto_compact_body.contains("You have exceeded the maximum number of tokens"), + "auto compact request should include the summarization prompt after exceeding 95% (limit {limit})" + ); +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 6008811dea..1a08c6df78 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -2,6 +2,8 @@ #[cfg(not(target_os = "windows"))] mod abort_tasks; +#[cfg(not(target_os = "windows"))] +mod approvals; mod cli_stream; mod client; mod compact; diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index d264eb61b0..67c072e021 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -68,8 +68,8 @@ pub struct Cli { pub json: bool, /// Whether to include the plan tool in the conversation. - #[arg(long = "include-plan-tool", default_value_t = false)] - pub include_plan_tool: bool, + #[arg(long = "include-plan-tool")] + pub include_plan_tool: Option, /// Specifies file where the last message from the agent should be written. #[arg(long = "output-last-message", short = 'o', value_name = "FILE")] diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 606cf9187a..032b912f86 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -57,11 +57,11 @@ pub struct TurnFailedEvent { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)] pub struct Usage { /// The number of input tokens used during the turn. - pub input_tokens: u64, + pub input_tokens: i64, /// The number of cached input tokens used during the turn. - pub cached_input_tokens: u64, + pub cached_input_tokens: i64, /// The number of output tokens used during the turn. - pub output_tokens: u64, + pub output_tokens: i64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 953c997685..afd9d44a73 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -74,6 +74,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config_overrides, } = cli; + if include_plan_tool.is_some() { + eprintln!("include-plan-tool is deprecated. Plan tool is now enabled by default."); + } + // Determine the prompt source (parent or subcommand) and read from stdin if needed. let prompt_arg = match &command { // Allow prompt before the subcommand by falling back to the parent-level prompt @@ -177,7 +181,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model_provider, codex_linux_sandbox_exe, base_instructions: None, - include_plan_tool: Some(include_plan_tool), + include_plan_tool: Some(include_plan_tool.unwrap_or(true)), include_apply_patch_tool: None, include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), diff --git a/codex-rs/mcp-types/Cargo.toml b/codex-rs/mcp-types/Cargo.toml index c8dc5819cd..8ea4fdfee5 100644 --- a/codex-rs/mcp-types/Cargo.toml +++ b/codex-rs/mcp-types/Cargo.toml @@ -10,3 +10,4 @@ workspace = true serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } ts-rs = { workspace = true, features = ["serde-json-impl", "no-serde-warnings"] } +schemars = { workspace = true } diff --git a/codex-rs/mcp-types/generate_mcp_types.py b/codex-rs/mcp-types/generate_mcp_types.py index 60c261e828..1906033537 100755 --- a/codex-rs/mcp-types/generate_mcp_types.py +++ b/codex-rs/mcp-types/generate_mcp_types.py @@ -21,9 +21,9 @@ from typing import Any, Literal SCHEMA_VERSION = "2025-06-18" JSONRPC_VERSION = "2.0" -STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)]\n" +STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]\n" STANDARD_HASHABLE_DERIVE = ( - "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)]\n" + "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)]\n" ) # Will be populated with the schema's `definitions` map in `main()` so that @@ -94,6 +94,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use std::convert::TryFrom; +use schemars::JsonSchema; use ts_rs::TS; pub const MCP_SCHEMA_VERSION: &str = "{SCHEMA_VERSION}"; diff --git a/codex-rs/mcp-types/src/lib.rs b/codex-rs/mcp-types/src/lib.rs index 37d790ab6a..98dab7252b 100644 --- a/codex-rs/mcp-types/src/lib.rs +++ b/codex-rs/mcp-types/src/lib.rs @@ -10,6 +10,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use std::convert::TryFrom; +use schemars::JsonSchema; use ts_rs::TS; pub const MCP_SCHEMA_VERSION: &str = "2025-06-18"; @@ -33,7 +34,7 @@ fn default_jsonrpc() -> String { } /// Optional annotations for the client. The client can use annotations to inform how objects are used or displayed -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Annotations { #[serde(default, skip_serializing_if = "Option::is_none")] pub audience: Option>, @@ -48,7 +49,7 @@ pub struct Annotations { } /// Audio provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct AudioContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -59,14 +60,14 @@ pub struct AudioContent { } /// Base interface for metadata with name (identifier) and title (display name) properties. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BaseMetadata { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BlobResourceContents { pub blob: String, #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] @@ -74,7 +75,7 @@ pub struct BlobResourceContents { pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BooleanSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub default: Option, @@ -85,7 +86,7 @@ pub struct BooleanSchema { pub r#type: String, // &'static str = "boolean" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CallToolRequest {} impl ModelContextProtocolRequest for CallToolRequest { @@ -94,7 +95,7 @@ impl ModelContextProtocolRequest for CallToolRequest { type Result = CallToolResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CallToolRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, @@ -102,7 +103,7 @@ pub struct CallToolRequestParams { } /// The server's response to a tool call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CallToolResult { pub content: Vec, #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] @@ -123,7 +124,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CancelledNotification {} impl ModelContextProtocolNotification for CancelledNotification { @@ -131,7 +132,7 @@ impl ModelContextProtocolNotification for CancelledNotification { type Params = CancelledNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CancelledNotificationParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, @@ -140,7 +141,7 @@ pub struct CancelledNotificationParams { } /// Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ClientCapabilities { #[serde(default, skip_serializing_if = "Option::is_none")] pub elicitation: Option, @@ -153,7 +154,7 @@ pub struct ClientCapabilities { } /// Present if the client supports listing roots. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ClientCapabilitiesRoots { #[serde( rename = "listChanged", @@ -163,7 +164,7 @@ pub struct ClientCapabilitiesRoots { pub list_changed: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ClientNotification { CancelledNotification(CancelledNotification), @@ -172,7 +173,7 @@ pub enum ClientNotification { RootsListChangedNotification(RootsListChangedNotification), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "method", content = "params")] pub enum ClientRequest { #[serde(rename = "initialize")] @@ -205,7 +206,7 @@ pub enum ClientRequest { CompleteRequest(::Params), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ClientResult { Result(Result), @@ -214,7 +215,7 @@ pub enum ClientResult { ElicitResult(ElicitResult), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CompleteRequest {} impl ModelContextProtocolRequest for CompleteRequest { @@ -223,7 +224,7 @@ impl ModelContextProtocolRequest for CompleteRequest { type Result = CompleteResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParams { pub argument: CompleteRequestParamsArgument, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -232,20 +233,20 @@ pub struct CompleteRequestParams { } /// Additional, optional context for completions -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParamsContext { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, } /// The argument's information -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParamsArgument { pub name: String, pub value: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum CompleteRequestParamsRef { PromptReference(PromptReference), @@ -253,12 +254,12 @@ pub enum CompleteRequestParamsRef { } /// The server's response to a completion/complete request -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteResult { pub completion: CompleteResultCompletion, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteResultCompletion { #[serde(rename = "hasMore", default, skip_serializing_if = "Option::is_none")] pub has_more: Option, @@ -275,7 +276,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ContentBlock { TextContent(TextContent), @@ -285,7 +286,7 @@ pub enum ContentBlock { EmbeddedResource(EmbeddedResource), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CreateMessageRequest {} impl ModelContextProtocolRequest for CreateMessageRequest { @@ -294,7 +295,7 @@ impl ModelContextProtocolRequest for CreateMessageRequest { type Result = CreateMessageResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CreateMessageRequestParams { #[serde( rename = "includeContext", @@ -330,7 +331,7 @@ pub struct CreateMessageRequestParams { } /// The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CreateMessageResult { pub content: CreateMessageResultContent, pub model: String, @@ -343,7 +344,7 @@ pub struct CreateMessageResult { pub stop_reason: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum CreateMessageResultContent { TextContent(TextContent), @@ -359,10 +360,10 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Cursor(String); -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ElicitRequest {} impl ModelContextProtocolRequest for ElicitRequest { @@ -371,7 +372,7 @@ impl ModelContextProtocolRequest for ElicitRequest { type Result = ElicitResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitRequestParams { pub message: String, #[serde(rename = "requestedSchema")] @@ -380,7 +381,7 @@ pub struct ElicitRequestParams { /// A restricted subset of JSON Schema. /// Only top-level properties are allowed, without nesting. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitRequestParamsRequestedSchema { pub properties: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -389,7 +390,7 @@ pub struct ElicitRequestParamsRequestedSchema { } /// The client's response to an elicitation request. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitResult { pub action: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -408,7 +409,7 @@ impl From for serde_json::Value { /// /// It is up to the client how best to render embedded resources for the benefit /// of the LLM and/or the user. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct EmbeddedResource { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -416,7 +417,7 @@ pub struct EmbeddedResource { pub r#type: String, // &'static str = "resource" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum EmbeddedResourceResource { TextResourceContents(TextResourceContents), @@ -425,7 +426,7 @@ pub enum EmbeddedResourceResource { pub type EmptyResult = Result; -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct EnumSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -437,7 +438,7 @@ pub struct EnumSchema { pub r#type: String, // &'static str = "string" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum GetPromptRequest {} impl ModelContextProtocolRequest for GetPromptRequest { @@ -446,7 +447,7 @@ impl ModelContextProtocolRequest for GetPromptRequest { type Result = GetPromptResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct GetPromptRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, @@ -454,7 +455,7 @@ pub struct GetPromptRequestParams { } /// The server's response to a prompts/get request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct GetPromptResult { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -470,7 +471,7 @@ impl From for serde_json::Value { } /// An image provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ImageContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -481,7 +482,7 @@ pub struct ImageContent { } /// Describes the name and version of an MCP implementation, with an optional title for UI representation. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Implementation { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -492,7 +493,7 @@ pub struct Implementation { pub user_agent: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum InitializeRequest {} impl ModelContextProtocolRequest for InitializeRequest { @@ -501,7 +502,7 @@ impl ModelContextProtocolRequest for InitializeRequest { type Result = InitializeResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct InitializeRequestParams { pub capabilities: ClientCapabilities, #[serde(rename = "clientInfo")] @@ -511,7 +512,7 @@ pub struct InitializeRequestParams { } /// After receiving an initialize request from the client, the server sends this response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct InitializeResult { pub capabilities: ServerCapabilities, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -530,7 +531,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum InitializedNotification {} impl ModelContextProtocolNotification for InitializedNotification { @@ -539,7 +540,7 @@ impl ModelContextProtocolNotification for InitializedNotification { } /// A response to a request that indicates an error occurred. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCError { pub error: JSONRPCErrorError, pub id: RequestId, @@ -547,7 +548,7 @@ pub struct JSONRPCError { pub jsonrpc: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCErrorError { pub code: i64, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -556,7 +557,7 @@ pub struct JSONRPCErrorError { } /// Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum JSONRPCMessage { Request(JSONRPCRequest), @@ -566,7 +567,7 @@ pub enum JSONRPCMessage { } /// A notification which does not expect a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCNotification { #[serde(rename = "jsonrpc", default = "default_jsonrpc")] pub jsonrpc: String, @@ -576,7 +577,7 @@ pub struct JSONRPCNotification { } /// A request that expects a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCRequest { pub id: RequestId, #[serde(rename = "jsonrpc", default = "default_jsonrpc")] @@ -587,7 +588,7 @@ pub struct JSONRPCRequest { } /// A successful (non-error) response to a request. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCResponse { pub id: RequestId, #[serde(rename = "jsonrpc", default = "default_jsonrpc")] @@ -595,7 +596,7 @@ pub struct JSONRPCResponse { pub result: Result, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListPromptsRequest {} impl ModelContextProtocolRequest for ListPromptsRequest { @@ -604,14 +605,14 @@ impl ModelContextProtocolRequest for ListPromptsRequest { type Result = ListPromptsResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListPromptsRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a prompts/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListPromptsResult { #[serde( rename = "nextCursor", @@ -630,7 +631,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListResourceTemplatesRequest {} impl ModelContextProtocolRequest for ListResourceTemplatesRequest { @@ -639,14 +640,14 @@ impl ModelContextProtocolRequest for ListResourceTemplatesRequest { type Result = ListResourceTemplatesResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourceTemplatesRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a resources/templates/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourceTemplatesResult { #[serde( rename = "nextCursor", @@ -666,7 +667,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListResourcesRequest {} impl ModelContextProtocolRequest for ListResourcesRequest { @@ -675,14 +676,14 @@ impl ModelContextProtocolRequest for ListResourcesRequest { type Result = ListResourcesResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourcesRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a resources/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourcesResult { #[serde( rename = "nextCursor", @@ -701,7 +702,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListRootsRequest {} impl ModelContextProtocolRequest for ListRootsRequest { @@ -713,7 +714,7 @@ impl ModelContextProtocolRequest for ListRootsRequest { /// The client's response to a roots/list request from the server. /// This result contains an array of Root objects, each representing a root directory /// or file that the server can operate on. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListRootsResult { pub roots: Vec, } @@ -726,7 +727,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListToolsRequest {} impl ModelContextProtocolRequest for ListToolsRequest { @@ -735,14 +736,14 @@ impl ModelContextProtocolRequest for ListToolsRequest { type Result = ListToolsResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListToolsRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a tools/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListToolsResult { #[serde( rename = "nextCursor", @@ -765,7 +766,7 @@ impl From for serde_json::Value { /// /// These map to syslog message severities, as specified in RFC-5424: /// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum LoggingLevel { #[serde(rename = "alert")] Alert, @@ -785,7 +786,7 @@ pub enum LoggingLevel { Warning, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum LoggingMessageNotification {} impl ModelContextProtocolNotification for LoggingMessageNotification { @@ -793,7 +794,7 @@ impl ModelContextProtocolNotification for LoggingMessageNotification { type Params = LoggingMessageNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct LoggingMessageNotificationParams { pub data: serde_json::Value, pub level: LoggingLevel, @@ -805,7 +806,7 @@ pub struct LoggingMessageNotificationParams { /// /// Keys not declared here are currently left unspecified by the spec and are up /// to the client to interpret. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ModelHint { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, @@ -822,7 +823,7 @@ pub struct ModelHint { /// These preferences are always advisory. The client MAY ignore them. It is also /// up to the client to decide how to interpret these preferences and how to /// balance them against other considerations. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ModelPreferences { #[serde( rename = "costPriority", @@ -846,14 +847,14 @@ pub struct ModelPreferences { pub speed_priority: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Notification { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct NumberSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -866,20 +867,20 @@ pub struct NumberSchema { pub r#type: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedRequest { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedResult { #[serde( rename = "nextCursor", @@ -897,7 +898,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum PingRequest {} impl ModelContextProtocolRequest for PingRequest { @@ -908,7 +909,7 @@ impl ModelContextProtocolRequest for PingRequest { /// Restricted schema definitions that only allow primitive types /// without nested objects or arrays. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum PrimitiveSchemaDefinition { StringSchema(StringSchema), @@ -917,7 +918,7 @@ pub enum PrimitiveSchemaDefinition { EnumSchema(EnumSchema), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ProgressNotification {} impl ModelContextProtocolNotification for ProgressNotification { @@ -925,7 +926,7 @@ impl ModelContextProtocolNotification for ProgressNotification { type Params = ProgressNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ProgressNotificationParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option, @@ -936,7 +937,7 @@ pub struct ProgressNotificationParams { pub total: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] #[serde(untagged)] pub enum ProgressToken { String(String), @@ -944,7 +945,7 @@ pub enum ProgressToken { } /// A prompt or prompt template that the server offers. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Prompt { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option>, @@ -956,7 +957,7 @@ pub struct Prompt { } /// Describes an argument that a prompt can accept. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptArgument { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -967,7 +968,7 @@ pub struct PromptArgument { pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum PromptListChangedNotification {} impl ModelContextProtocolNotification for PromptListChangedNotification { @@ -979,14 +980,14 @@ impl ModelContextProtocolNotification for PromptListChangedNotification { /// /// This is similar to `SamplingMessage`, but also supports the embedding of /// resources from the MCP server. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptMessage { pub content: ContentBlock, pub role: Role, } /// Identifies a prompt. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptReference { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -994,7 +995,7 @@ pub struct PromptReference { pub r#type: String, // &'static str = "ref/prompt" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ReadResourceRequest {} impl ModelContextProtocolRequest for ReadResourceRequest { @@ -1003,18 +1004,18 @@ impl ModelContextProtocolRequest for ReadResourceRequest { type Result = ReadResourceResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ReadResourceRequestParams { pub uri: String, } /// The server's response to a resources/read request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ReadResourceResult { pub contents: Vec, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ReadResourceResultContents { TextResourceContents(TextResourceContents), @@ -1029,14 +1030,14 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Request { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] #[serde(untagged)] pub enum RequestId { String(String), @@ -1044,7 +1045,7 @@ pub enum RequestId { } /// A known resource that the server is capable of reading. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Resource { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1061,7 +1062,7 @@ pub struct Resource { } /// The contents of a specific resource or sub-resource. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceContents { #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] pub mime_type: Option, @@ -1071,7 +1072,7 @@ pub struct ResourceContents { /// A resource that the server is capable of reading, included in a prompt or tool call result. /// /// Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceLink { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1088,7 +1089,7 @@ pub struct ResourceLink { pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ResourceListChangedNotification {} impl ModelContextProtocolNotification for ResourceListChangedNotification { @@ -1097,7 +1098,7 @@ impl ModelContextProtocolNotification for ResourceListChangedNotification { } /// A template description for resources available on the server. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceTemplate { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1113,13 +1114,13 @@ pub struct ResourceTemplate { } /// A reference to a resource or resource template definition. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceTemplateReference { pub r#type: String, // &'static str = "ref/resource" pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ResourceUpdatedNotification {} impl ModelContextProtocolNotification for ResourceUpdatedNotification { @@ -1127,7 +1128,7 @@ impl ModelContextProtocolNotification for ResourceUpdatedNotification { type Params = ResourceUpdatedNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceUpdatedNotificationParams { pub uri: String, } @@ -1135,7 +1136,7 @@ pub struct ResourceUpdatedNotificationParams { pub type Result = serde_json::Value; /// The sender or recipient of messages and data in a conversation. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum Role { #[serde(rename = "assistant")] Assistant, @@ -1144,14 +1145,14 @@ pub enum Role { } /// Represents a root directory or file that the server can operate on. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Root { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum RootsListChangedNotification {} impl ModelContextProtocolNotification for RootsListChangedNotification { @@ -1160,13 +1161,13 @@ impl ModelContextProtocolNotification for RootsListChangedNotification { } /// Describes a message issued to or received from an LLM API. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SamplingMessage { pub content: SamplingMessageContent, pub role: Role, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum SamplingMessageContent { TextContent(TextContent), @@ -1175,7 +1176,7 @@ pub enum SamplingMessageContent { } /// Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilities { #[serde(default, skip_serializing_if = "Option::is_none")] pub completions: Option, @@ -1192,7 +1193,7 @@ pub struct ServerCapabilities { } /// Present if the server offers any tools to call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesTools { #[serde( rename = "listChanged", @@ -1203,7 +1204,7 @@ pub struct ServerCapabilitiesTools { } /// Present if the server offers any resources to read. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesResources { #[serde( rename = "listChanged", @@ -1216,7 +1217,7 @@ pub struct ServerCapabilitiesResources { } /// Present if the server offers any prompt templates. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesPrompts { #[serde( rename = "listChanged", @@ -1226,7 +1227,7 @@ pub struct ServerCapabilitiesPrompts { pub list_changed: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "method", content = "params")] pub enum ServerNotification { #[serde(rename = "notifications/cancelled")] @@ -1255,7 +1256,7 @@ pub enum ServerNotification { ), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ServerRequest { PingRequest(PingRequest), @@ -1264,7 +1265,7 @@ pub enum ServerRequest { ElicitRequest(ElicitRequest), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub enum ServerResult { @@ -1280,7 +1281,7 @@ pub enum ServerResult { CompleteResult(CompleteResult), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum SetLevelRequest {} impl ModelContextProtocolRequest for SetLevelRequest { @@ -1289,12 +1290,12 @@ impl ModelContextProtocolRequest for SetLevelRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SetLevelRequestParams { pub level: LoggingLevel, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct StringSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -1309,7 +1310,7 @@ pub struct StringSchema { pub r#type: String, // &'static str = "string" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum SubscribeRequest {} impl ModelContextProtocolRequest for SubscribeRequest { @@ -1318,13 +1319,13 @@ impl ModelContextProtocolRequest for SubscribeRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SubscribeRequestParams { pub uri: String, } /// Text provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct TextContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1332,7 +1333,7 @@ pub struct TextContent { pub r#type: String, // &'static str = "text" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct TextResourceContents { #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] pub mime_type: Option, @@ -1341,7 +1342,7 @@ pub struct TextResourceContents { } /// Definition for a tool the client can call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Tool { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1362,7 +1363,7 @@ pub struct Tool { /// An optional JSON Schema object defining the structure of the tool's output returned in /// the structuredContent field of a CallToolResult. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolOutputSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub properties: Option, @@ -1372,7 +1373,7 @@ pub struct ToolOutputSchema { } /// A JSON Schema object defining the expected parameters for the tool. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolInputSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub properties: Option, @@ -1389,7 +1390,7 @@ pub struct ToolInputSchema { /// /// Clients should never make tool use decisions based on ToolAnnotations /// received from untrusted servers. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolAnnotations { #[serde( rename = "destructiveHint", @@ -1419,7 +1420,7 @@ pub struct ToolAnnotations { pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ToolListChangedNotification {} impl ModelContextProtocolNotification for ToolListChangedNotification { @@ -1427,7 +1428,7 @@ impl ModelContextProtocolNotification for ToolListChangedNotification { type Params = Option; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum UnsubscribeRequest {} impl ModelContextProtocolRequest for UnsubscribeRequest { @@ -1436,7 +1437,7 @@ impl ModelContextProtocolRequest for UnsubscribeRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct UnsubscribeRequestParams { pub uri: String, } diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index c02ea19fdf..666d83f822 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -86,8 +86,8 @@ impl OtelEventManager { provider_name: &str, reasoning_effort: Option, reasoning_summary: ReasoningSummary, - context_window: Option, - max_output_tokens: Option, + context_window: Option, + max_output_tokens: Option, auto_compact_token_limit: Option, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, @@ -281,11 +281,11 @@ impl OtelEventManager { pub fn sse_event_completed( &self, - input_token_count: u64, - output_token_count: u64, - cached_token_count: Option, - reasoning_token_count: Option, - tool_token_count: u64, + input_token_count: i64, + output_token_count: i64, + cached_token_count: Option, + reasoning_token_count: Option, + tool_token_count: i64, ) { tracing::event!( tracing::Level::INFO, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 72cf984e96..f21d326610 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -19,6 +19,7 @@ mime_guess = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_with = { workspace = true, features = ["macros", "base64"] } +schemars = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } sys-locale = { workspace = true } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index d701ecf41e..32950527bc 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,3 +1,4 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; @@ -6,7 +7,18 @@ use ts_rs::TS; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning #[derive( - Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS, EnumIter, + Debug, + Serialize, + Deserialize, + Default, + Clone, + Copy, + PartialEq, + Eq, + Display, + JsonSchema, + TS, + EnumIter, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] @@ -21,7 +33,9 @@ pub enum ReasoningEffort { /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ReasoningSummary { @@ -35,7 +49,9 @@ pub enum ReasoningSummary { /// Controls output length/detail on GPT-5 models via the Responses API. /// Serialized with lowercase values to match the OpenAI API. -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Verbosity { @@ -45,7 +61,9 @@ pub enum Verbosity { High, } -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, TS)] +#[derive( + Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, JsonSchema, TS, +)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum SandboxMode { @@ -60,7 +78,7 @@ pub enum SandboxMode { DangerFullAccess, } -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ForcedLoginMethod { diff --git a/codex-rs/protocol/src/conversation_id.rs b/codex-rs/protocol/src/conversation_id.rs index 687237e0ab..db104d4531 100644 --- a/codex-rs/protocol/src/conversation_id.rs +++ b/codex-rs/protocol/src/conversation_id.rs @@ -1,5 +1,8 @@ use std::fmt::Display; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; @@ -57,6 +60,16 @@ impl<'de> Deserialize<'de> for ConversationId { } } +impl JsonSchema for ConversationId { + fn schema_name() -> String { + "ConversationId".to_string() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + ::json_schema(generator) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs index 4839cba3c2..7472d1b4c5 100644 --- a/codex-rs/protocol/src/custom_prompts.rs +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -1,3 +1,4 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; @@ -9,7 +10,7 @@ use ts_rs::TS; /// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"` pub const PROMPTS_CMD_PREFIX: &str = "prompts"; -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct CustomPrompt { pub name: String, pub path: PathBuf, diff --git a/codex-rs/protocol/src/message_history.rs b/codex-rs/protocol/src/message_history.rs index ba79993131..0d8bd8df4e 100644 --- a/codex-rs/protocol/src/message_history.rs +++ b/codex-rs/protocol/src/message_history.rs @@ -1,8 +1,9 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct HistoryEntry { pub conversation_id: String, pub ts: u64, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 4952aa01d9..285ae666f9 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -9,8 +9,9 @@ use serde::ser::Serializer; use ts_rs::TS; use crate::protocol::InputItem; +use schemars::JsonSchema; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { Message { @@ -31,7 +32,7 @@ pub enum ResponseInputItem { }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentItem { InputText { text: String }, @@ -39,7 +40,7 @@ pub enum ContentItem { OutputText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { @@ -159,7 +160,7 @@ impl From for ResponseItem { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum LocalShellStatus { Completed, @@ -167,13 +168,13 @@ pub enum LocalShellStatus { Incomplete, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LocalShellAction { Exec(LocalShellExecAction), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] pub struct LocalShellExecAction { pub command: Vec, pub timeout_ms: Option, @@ -182,7 +183,7 @@ pub struct LocalShellExecAction { pub user: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WebSearchAction { Search { @@ -192,13 +193,13 @@ pub enum WebSearchAction { Other, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemReasoningSummary { SummaryText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemContent { ReasoningText { text: String }, @@ -242,7 +243,7 @@ impl From> for ResponseInputItem { /// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` /// or shell`, the `arguments` field should deserialize to this struct. -#[derive(Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ShellToolCallParams { pub command: Vec, pub workdir: Option, @@ -256,7 +257,7 @@ pub struct ShellToolCallParams { pub justification: Option, } -#[derive(Debug, Clone, PartialEq, TS)] +#[derive(Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FunctionCallOutputPayload { pub content: String, // TODO(jif) drop this. diff --git a/codex-rs/protocol/src/num_format.rs b/codex-rs/protocol/src/num_format.rs index 72a4ee722d..2c64939b7a 100644 --- a/codex-rs/protocol/src/num_format.rs +++ b/codex-rs/protocol/src/num_format.rs @@ -22,27 +22,28 @@ fn formatter() -> &'static DecimalFormatter { FORMATTER.get_or_init(|| make_local_formatter().unwrap_or_else(make_en_us_formatter)) } -/// Format a u64 with locale-aware digit separators (e.g. "12345" -> "12,345" +/// Format an i64 with locale-aware digit separators (e.g. "12345" -> "12,345" /// for en-US). -pub fn format_with_separators(n: u64) -> String { +pub fn format_with_separators(n: i64) -> String { formatter().format(&Decimal::from(n)).to_string() } -fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> String { +fn format_si_suffix_with_formatter(n: i64, formatter: &DecimalFormatter) -> String { + let n = n.max(0); if n < 1000 { return formatter.format(&Decimal::from(n)).to_string(); } // Format `n / scale` with the requested number of fractional digits. - let format_scaled = |n: u64, scale: u64, frac_digits: u32| -> String { + let format_scaled = |n: i64, scale: i64, frac_digits: u32| -> String { let value = n as f64 / scale as f64; - let scaled: u64 = (value * 10f64.powi(frac_digits as i32)).round() as u64; + let scaled: i64 = (value * 10f64.powi(frac_digits as i32)).round() as i64; let mut dec = Decimal::from(scaled); dec.multiply_pow10(-(frac_digits as i16)); formatter.format(&dec).to_string() }; - const UNITS: [(u64, &str); 3] = [(1_000, "K"), (1_000_000, "M"), (1_000_000_000, "G")]; + const UNITS: [(i64, &str); 3] = [(1_000, "K"), (1_000_000, "M"), (1_000_000_000, "G")]; let f = n as f64; for &(scale, suffix) in &UNITS { if (100.0 * f / scale as f64).round() < 1000.0 { @@ -57,7 +58,7 @@ fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> Stri // Above 1000G, keep whole‑G precision. format!( "{}G", - format_with_separators(((n as f64) / 1e9).round() as u64) + format_with_separators(((n as f64) / 1e9).round() as i64) ) } @@ -67,7 +68,7 @@ fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> Stri /// - 999 -> "999" /// - 1200 -> "1.20K" /// - 123456789 -> "123M" -pub fn format_si_suffix(n: u64) -> String { +pub fn format_si_suffix(n: i64) -> String { format_si_suffix_with_formatter(n, formatter()) } @@ -78,7 +79,7 @@ mod tests { #[test] fn kmg() { let formatter = make_en_us_formatter(); - let fmt = |n: u64| format_si_suffix_with_formatter(n, &formatter); + let fmt = |n: i64| format_si_suffix_with_formatter(n, &formatter); assert_eq!(fmt(0), "0"); assert_eq!(fmt(999), "999"); assert_eq!(fmt(1_000), "1.00K"); diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index b06e290436..77926f11b6 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -1,9 +1,10 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; use ts_rs::TS; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ParsedCommand { Read { diff --git a/codex-rs/protocol/src/plan_tool.rs b/codex-rs/protocol/src/plan_tool.rs index 79e672ac73..a9038eb03b 100644 --- a/codex-rs/protocol/src/plan_tool.rs +++ b/codex-rs/protocol/src/plan_tool.rs @@ -1,9 +1,10 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; // Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum StepStatus { Pending, @@ -11,14 +12,14 @@ pub enum StepStatus { Completed, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct PlanItemArg { pub step: String, pub status: StepStatus, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct UpdatePlanArgs { #[serde(default)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e56e54af92..7fc9a1ccdb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -24,6 +24,7 @@ use mcp_types::CallToolResult; use mcp_types::Resource as McpResource; use mcp_types::ResourceTemplate as McpResourceTemplate; use mcp_types::Tool as McpTool; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -40,7 +41,7 @@ pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; /// Submission Queue Entry - requests from user -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { /// Unique id for this Submission to correlate with Events pub id: String, @@ -49,7 +50,7 @@ pub struct Submission { } /// Submission operation -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] #[allow(clippy::large_enum_variant)] #[non_exhaustive] @@ -182,7 +183,20 @@ pub enum Op { /// Determines the conditions under which the user is consulted to approve /// running the command proposed by Codex. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display, TS)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Display, + JsonSchema, + TS, +)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum AskForApproval { @@ -209,7 +223,7 @@ pub enum AskForApproval { } /// Determines execution restrictions for model shell commands. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] #[strum(serialize_all = "kebab-case")] #[serde(tag = "mode", rename_all = "kebab-case")] pub enum SandboxPolicy { @@ -252,7 +266,7 @@ pub enum SandboxPolicy { /// read‑only even when the root is writable. This is primarily used to ensure /// top‑level VCS metadata directories (e.g. `.git`) under a writable root are /// not modified by the agent. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)] pub struct WritableRoot { /// Absolute path, by construction. pub root: PathBuf, @@ -391,7 +405,7 @@ impl SandboxPolicy { /// User input #[non_exhaustive] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InputItem { Text { @@ -410,7 +424,7 @@ pub enum InputItem { } /// Event Queue Entry - events from agent -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Event { /// Submission `id` that this event is correlated with. pub id: String, @@ -420,7 +434,7 @@ pub struct Event { /// Response event from the agent /// NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. -#[derive(Debug, Clone, Deserialize, Serialize, Display, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, Display, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum EventMsg { @@ -526,55 +540,55 @@ pub enum EventMsg { ExitedReviewMode(ExitedReviewModeEvent), } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExitedReviewModeEvent { pub review_output: Option, } // Individual event payload types matching each `EventMsg` variant. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ErrorEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TaskCompleteEvent { pub last_agent_message: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TaskStartedEvent { - pub model_context_window: Option, + pub model_context_window: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema, TS)] pub struct TokenUsage { #[ts(type = "number")] - pub input_tokens: u64, + pub input_tokens: i64, #[ts(type = "number")] - pub cached_input_tokens: u64, + pub cached_input_tokens: i64, #[ts(type = "number")] - pub output_tokens: u64, + pub output_tokens: i64, #[ts(type = "number")] - pub reasoning_output_tokens: u64, + pub reasoning_output_tokens: i64, #[ts(type = "number")] - pub total_tokens: u64, + pub total_tokens: i64, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TokenUsageInfo { pub total_token_usage: TokenUsage, pub last_token_usage: TokenUsage, #[ts(type = "number | null")] - pub model_context_window: Option, + pub model_context_window: Option, } impl TokenUsageInfo { pub fn new_or_append( info: &Option, last: &Option, - model_context_window: Option, + model_context_window: Option, ) -> Option { if info.is_none() && last.is_none() { return None; @@ -599,9 +613,9 @@ impl TokenUsageInfo { self.last_token_usage = last.clone(); } - pub fn fill_to_context_window(&mut self, context_window: u64) { + pub fn fill_to_context_window(&mut self, context_window: i64) { let previous_total = self.total_token_usage.total_tokens; - let delta = context_window.saturating_sub(previous_total); + let delta = (context_window - previous_total).max(0); self.model_context_window = Some(context_window); self.total_token_usage = TokenUsage { @@ -614,7 +628,7 @@ impl TokenUsageInfo { }; } - pub fn full_context_window(context_window: u64) -> Self { + pub fn full_context_window(context_window: i64) -> Self { let mut info = Self { total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), @@ -625,19 +639,19 @@ impl TokenUsageInfo { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TokenCountEvent { pub info: Option, pub rate_limits: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed. pub used_percent: f64, @@ -650,33 +664,32 @@ pub struct RateLimitWindow { } // Includes prompts, tools and space to call compact. -const BASELINE_TOKENS: u64 = 12000; +const BASELINE_TOKENS: i64 = 12000; impl TokenUsage { pub fn is_zero(&self) -> bool { self.total_tokens == 0 } - pub fn cached_input(&self) -> u64 { - self.cached_input_tokens + pub fn cached_input(&self) -> i64 { + self.cached_input_tokens.max(0) } - pub fn non_cached_input(&self) -> u64 { - self.input_tokens.saturating_sub(self.cached_input()) + pub fn non_cached_input(&self) -> i64 { + (self.input_tokens - self.cached_input()).max(0) } /// Primary count for display as a single absolute value: non-cached input + output. - pub fn blended_total(&self) -> u64 { - self.non_cached_input() + self.output_tokens + pub fn blended_total(&self) -> i64 { + (self.non_cached_input() + self.output_tokens.max(0)).max(0) } /// For estimating what % of the model's context window is used, we need to account /// for reasoning output tokens from prior turns being dropped from the context window. /// We approximate this here by subtracting reasoning output tokens from the total. /// This will be off for the current turn and pending function calls. - pub fn tokens_in_context_window(&self) -> u64 { - self.total_tokens - .saturating_sub(self.reasoning_output_tokens) + pub fn tokens_in_context_window(&self) -> i64 { + (self.total_tokens - self.reasoning_output_tokens).max(0) } /// Estimate the remaining user-controllable percentage of the model's context window. @@ -689,17 +702,17 @@ impl TokenUsage { /// This normalizes both the numerator and denominator by subtracting the /// baseline, so immediately after the first prompt the UI shows 100% left /// and trends toward 0% as the user fills the effective window. - pub fn percent_of_context_window_remaining(&self, context_window: u64) -> u8 { + pub fn percent_of_context_window_remaining(&self, context_window: i64) -> i64 { if context_window <= BASELINE_TOKENS { return 0; } let effective_window = context_window - BASELINE_TOKENS; - let used = self - .tokens_in_context_window() - .saturating_sub(BASELINE_TOKENS); - let remaining = effective_window.saturating_sub(used); - ((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8 + let used = (self.tokens_in_context_window() - BASELINE_TOKENS).max(0); + let remaining = (effective_window - used).max(0); + ((remaining as f64 / effective_window as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as i64 } /// In-place element-wise sum of token counts. @@ -712,7 +725,7 @@ impl TokenUsage { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct FinalOutput { pub token_usage: TokenUsage, } @@ -753,12 +766,12 @@ impl fmt::Display for FinalOutput { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentMessageEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum InputMessageKind { /// Plain user text (default) @@ -769,7 +782,7 @@ pub enum InputMessageKind { EnvironmentContext, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct UserMessageEvent { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -821,35 +834,35 @@ fn ends_with_ignore_ascii_case(text: &str, suffix: &str) -> bool { .all(|(a, b)| a.eq_ignore_ascii_case(b)) } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentMessageDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningEvent { pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningRawContentEvent { pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningRawContentDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningSectionBreakEvent {} -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpInvocation { /// Name of the MCP server as defined in the config. pub server: String, @@ -859,14 +872,14 @@ pub struct McpInvocation { pub arguments: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpToolCallBeginEvent { /// Identifier so this can be paired with the McpToolCallEnd event. pub call_id: String, pub invocation: McpInvocation, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpToolCallEndEvent { /// Identifier for the corresponding McpToolCallBegin that finished. pub call_id: String, @@ -886,12 +899,12 @@ impl McpToolCallEndEvent { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct WebSearchBeginEvent { pub call_id: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct WebSearchEndEvent { pub call_id: String, pub query: String, @@ -899,20 +912,20 @@ pub struct WebSearchEndEvent { /// Response payload for `Op::GetHistory` containing the current session's /// in-memory transcript. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ConversationPathResponseEvent { pub conversation_id: ConversationId, pub path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ResumedHistory { pub conversation_id: ConversationId, pub history: Vec, pub rollout_path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub enum InitialHistory { New, Resumed(ResumedHistory), @@ -954,7 +967,7 @@ impl InitialHistory { } } -#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, TS, Default)] +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)] #[serde(rename_all = "lowercase")] #[ts(rename_all = "lowercase")] pub enum SessionSource { @@ -967,7 +980,7 @@ pub enum SessionSource { Unknown, } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { pub id: ConversationId, pub timestamp: String, @@ -993,7 +1006,7 @@ impl Default for SessionMeta { } } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct SessionMetaLine { #[serde(flatten)] pub meta: SessionMeta, @@ -1001,7 +1014,7 @@ pub struct SessionMetaLine { pub git: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum RolloutItem { SessionMeta(SessionMetaLine), @@ -1011,7 +1024,7 @@ pub enum RolloutItem { EventMsg(EventMsg), } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct CompactedItem { pub message: String, } @@ -1028,7 +1041,7 @@ impl From for ResponseItem { } } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct TurnContextItem { pub cwd: PathBuf, pub approval_policy: AskForApproval, @@ -1039,14 +1052,14 @@ pub struct TurnContextItem { pub summary: ReasoningSummaryConfig, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, JsonSchema)] pub struct RolloutLine { pub timestamp: String, #[serde(flatten)] pub item: RolloutItem, } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct GitInfo { /// Current commit hash (SHA) #[serde(skip_serializing_if = "Option::is_none")] @@ -1060,14 +1073,14 @@ pub struct GitInfo { } /// Review request sent to the review session. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewRequest { pub prompt: String, pub user_facing_hint: String, } /// Structured review result produced by a child review session. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewOutputEvent { pub findings: Vec, pub overall_correctness: String, @@ -1087,7 +1100,7 @@ impl Default for ReviewOutputEvent { } /// A single review finding describing an observed issue or recommendation. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewFinding { pub title: String, pub body: String, @@ -1097,20 +1110,20 @@ pub struct ReviewFinding { } /// Location of the code related to a review finding. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewCodeLocation { pub absolute_file_path: PathBuf, pub line_range: ReviewLineRange, } /// Inclusive line range in a file associated with the finding. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewLineRange { pub start: u32, pub end: u32, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecCommandBeginEvent { /// Identifier so this can be paired with the ExecCommandEnd event. pub call_id: String, @@ -1121,7 +1134,7 @@ pub struct ExecCommandBeginEvent { pub parsed_cmd: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecCommandEndEvent { /// Identifier for the ExecCommandBegin that finished. pub call_id: String, @@ -1141,7 +1154,7 @@ pub struct ExecCommandEndEvent { pub formatted_output: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ViewImageToolCallEvent { /// Identifier for the originating tool call. pub call_id: String, @@ -1149,7 +1162,7 @@ pub struct ViewImageToolCallEvent { pub path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ExecOutputStream { Stdout, @@ -1157,7 +1170,7 @@ pub enum ExecOutputStream { } #[serde_as] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ExecCommandOutputDeltaEvent { /// Identifier for the ExecCommandBegin that produced this chunk. pub call_id: String, @@ -1165,11 +1178,12 @@ pub struct ExecCommandOutputDeltaEvent { pub stream: ExecOutputStream, /// Raw bytes from the stream (may not be valid UTF-8). #[serde_as(as = "serde_with::base64::Base64")] + #[schemars(with = "String")] #[ts(type = "string")] pub chunk: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. pub call_id: String, @@ -1183,7 +1197,7 @@ pub struct ExecApprovalRequestEvent { pub parsed_cmd: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ApplyPatchApprovalRequestEvent { /// Responses API call id for the associated patch apply call, if available. pub call_id: String, @@ -1196,22 +1210,22 @@ pub struct ApplyPatchApprovalRequestEvent { pub grant_root: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct StreamErrorEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct StreamInfoEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct PatchApplyBeginEvent { /// Identifier so this can be paired with the PatchApplyEnd event. pub call_id: String, @@ -1221,7 +1235,7 @@ pub struct PatchApplyBeginEvent { pub changes: HashMap, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct PatchApplyEndEvent { /// Identifier for the PatchApplyBegin that finished. pub call_id: String, @@ -1233,12 +1247,12 @@ pub struct PatchApplyEndEvent { pub success: bool, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnDiffEvent { pub unified_diff: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct GetHistoryEntryResponseEvent { pub offset: usize, pub log_id: u64, @@ -1247,7 +1261,7 @@ pub struct GetHistoryEntryResponseEvent { pub entry: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. pub tools: std::collections::HashMap, @@ -1259,7 +1273,7 @@ pub struct McpListToolsResponseEvent { pub auth_statuses: std::collections::HashMap, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] pub enum McpAuthStatus { @@ -1282,12 +1296,12 @@ impl fmt::Display for McpAuthStatus { } /// Response payload for `Op::ListCustomPrompts`. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } -#[derive(Debug, Default, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. pub session_id: ConversationId, @@ -1314,7 +1328,9 @@ pub struct SessionConfiguredEvent { } /// User's decision in response to an ExecApprovalRequest. -#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. @@ -1335,7 +1351,7 @@ pub enum ReviewDecision { Abort, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum FileChange { Add { @@ -1350,7 +1366,7 @@ pub enum FileChange { }, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct Chunk { /// 1-based line index of the first line in the original file pub orig_index: u32, @@ -1358,12 +1374,12 @@ pub struct Chunk { pub inserted_lines: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnAbortedEvent { pub reason: TurnAbortReason, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum TurnAbortReason { Interrupted, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index ce0e3c9c95..e8ecc084c4 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -485,11 +485,10 @@ mod tests { }) .collect(); let expected = vec![ - "✔ You approved codex to".to_string(), - " run /bin/zsh -lc 'git add".to_string(), - " tui/src/render/mod.rs tui/".to_string(), - " src/render/renderable.rs'".to_string(), - " this time".to_string(), + "✔ You approved codex to run".to_string(), + " git add tui/src/render/".to_string(), + " mod.rs tui/src/render/".to_string(), + " renderable.rs this time".to_string(), ]; assert_eq!(rendered, expected); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 56a6e44de3..a7793205f5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -108,7 +108,7 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, - context_window_percent: Option, + context_window_percent: Option, } /// Popup state – at most one can be visible at any time. @@ -1511,7 +1511,7 @@ impl ChatComposer { self.is_task_running = running; } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent != percent { self.context_window_percent = percent; } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 6e92a0ce68..79d7c60fa7 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -17,7 +17,7 @@ pub(crate) struct FooterProps { pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, - pub(crate) context_window_percent: Option, + pub(crate) context_window_percent: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -221,8 +221,8 @@ fn build_columns(entries: Vec>) -> Vec> { .collect() } -fn context_window_line(percent: Option) -> Line<'static> { - let percent = percent.unwrap_or(100); +fn context_window_line(percent: Option) -> Line<'static> { + let percent = percent.unwrap_or(100).clamp(0, 100); Line::from(vec![Span::from(format!("{percent}% context left")).dim()]) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8d9e84f2aa..69405cd823 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -70,7 +70,7 @@ pub(crate) struct BottomPane { status: Option, /// Queued user messages to show under the status indicator. queued_user_messages: Vec, - context_window_percent: Option, + context_window_percent: Option, } pub(crate) struct BottomPaneParams { @@ -357,7 +357,7 @@ impl BottomPane { } } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent == percent { return; } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9f308cd00f..8dc6454e4b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -142,9 +142,9 @@ impl RateLimitWarningState { fn take_warnings( &mut self, secondary_used_percent: Option, - secondary_window_minutes: Option, + secondary_window_minutes: Option, primary_used_percent: Option, - primary_window_minutes: Option, + primary_window_minutes: Option, ) -> Vec { let reached_secondary_cap = matches!(secondary_used_percent, Some(percent) if percent == 100.0); @@ -195,12 +195,14 @@ impl RateLimitWarningState { } } -pub(crate) fn get_limits_duration(windows_minutes: u64) -> String { - const MINUTES_PER_HOUR: u64 = 60; - const MINUTES_PER_DAY: u64 = 24 * MINUTES_PER_HOUR; - const MINUTES_PER_WEEK: u64 = 7 * MINUTES_PER_DAY; - const MINUTES_PER_MONTH: u64 = 30 * MINUTES_PER_DAY; - const ROUNDING_BIAS_MINUTES: u64 = 3; +pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { + const MINUTES_PER_HOUR: i64 = 60; + const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; + const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; + const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + const ROUNDING_BIAS_MINUTES: i64 = 3; + + let windows_minutes = windows_minutes.max(0); if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 5c9a0d039a..70ec4e84c7 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -8,9 +8,17 @@ pub(crate) fn escape_command(command: &[String]) -> String { try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } +fn is_login_shell_with_lc(shell: &str) -> bool { + let shell_name = std::path::Path::new(shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(shell); + matches!(shell_name, "bash" | "zsh") +} + pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { match command { - [first, second, third] if first == "bash" && second == "-lc" => third.clone(), + [first, second, third] if is_login_shell_with_lc(first) && second == "-lc" => third.clone(), _ => escape_command(command), } } @@ -46,8 +54,24 @@ mod tests { #[test] fn test_strip_bash_lc_and_escape() { + // Test bash let args = vec!["bash".into(), "-lc".into(), "echo hello".into()]; let cmdline = strip_bash_lc_and_escape(&args); assert_eq!(cmdline, "echo hello"); + + // Test zsh + let args = vec!["zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to zsh + let args = vec!["/usr/bin/zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to bash + let args = vec!["/bin/bash".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); } } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 4832713bf9..a077a92661 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -31,16 +31,16 @@ use super::rate_limits::render_status_limit_progress_bar; #[derive(Debug, Clone)] struct StatusContextWindowData { - percent_remaining: u8, - tokens_in_context: u64, - window: u64, + percent_remaining: i64, + tokens_in_context: i64, + window: i64, } #[derive(Debug, Clone)] pub(crate) struct StatusTokenUsageData { - total: u64, - input: u64, - output: u64, + total: i64, + input: i64, + output: i64, context_window: Option, } diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 1889d7f4f4..59362e2e2b 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -103,7 +103,8 @@ pub(crate) fn compose_account_display(config: &Config) -> Option String { +pub(crate) fn format_tokens_compact(value: i64) -> String { + let value = value.max(0); if value == 0 { return "0".to_string(); } @@ -111,14 +112,15 @@ pub(crate) fn format_tokens_compact(value: u64) -> String { return value.to_string(); } + let value_f64 = value as f64; let (scaled, suffix) = if value >= 1_000_000_000_000 { - (value as f64 / 1_000_000_000_000.0, "T") + (value_f64 / 1_000_000_000_000.0, "T") } else if value >= 1_000_000_000 { - (value as f64 / 1_000_000_000.0, "B") + (value_f64 / 1_000_000_000.0, "B") } else if value >= 1_000_000 { - (value as f64 / 1_000_000.0, "M") + (value_f64 / 1_000_000.0, "M") } else { - (value as f64 / 1_000.0, "K") + (value_f64 / 1_000.0, "K") }; let decimals = if scaled < 10.0 { diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 4e4f16b9ba..299f297d00 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -29,7 +29,7 @@ pub(crate) enum StatusRateLimitData { pub(crate) struct RateLimitWindowDisplay { pub used_percent: f64, pub resets_at: Option, - pub window_minutes: Option, + pub window_minutes: Option, } impl RateLimitWindowDisplay { diff --git a/docs/advanced.md b/docs/advanced.md index 4bf05a8032..ebcd6e34fd 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -40,17 +40,16 @@ Send a `tools/list` request and you will see that there are two tools available: **`codex`** - Run a Codex session. Accepts configuration parameters matching the Codex Config struct. The `codex` tool takes the following properties: -| Property | Type | Description | -| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. | -| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. | -| `base-instructions` | string | The set of instructions to use instead of the default ones. | -| `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. | -| `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. | -| `include-plan-tool` | boolean | Whether to include the plan tool in the conversation. | -| `model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). | -| `profile` | string | Configuration profile from `config.toml` to specify default options. | -| `sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. | +| Property | Type | Description | +| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | +| **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. | +| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. | +| `base-instructions` | string | The set of instructions to use instead of the default ones. | +| `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. | +| `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. | | +| `model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). | +| `profile` | string | Configuration profile from `config.toml` to specify default options. | +| `sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. | **`codex-reply`** - Continue a Codex session by providing the conversation id and prompt. The `codex-reply` tool takes the following properties: diff --git a/docs/contributing.md b/docs/contributing.md index eb67f1ce59..155f9f1ebe 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -87,7 +87,7 @@ This will push the commit using the tag `rust-v${VERSION}`, which in turn kicks If everything looks good in the generated GitHub Release, uncheck the **pre-release** box so it is the latest release. -Create a PR to update [`Formula/c/codex.rb`](https://github.com/Homebrew/homebrew-core/blob/main/Formula/c/codex.rb) on Homebrew. +Create a PR to update [`Cask/c/codex.rb`](https://github.com/Homebrew/homebrew-cask/blob/main/Formula/c/codex.rb) on Homebrew. ### Security & responsible AI diff --git a/docs/release_management.md b/docs/release_management.md index d2b3264554..05148b1c93 100644 --- a/docs/release_management.md +++ b/docs/release_management.md @@ -4,7 +4,7 @@ Currently, we made Codex binaries available in three places: - GitHub Releases https://github.com/openai/codex/releases/ - `@openai/codex` on npm: https://www.npmjs.com/package/@openai/codex -- `codex` on Homebrew: https://formulae.brew.sh/formula/codex +- `codex` on Homebrew: https://formulae.brew.sh/cask/codex # Cutting a Release @@ -34,14 +34,12 @@ The GitHub Action is responsible for publishing to npm. ## Publishing to Homebrew -For Homebrew, we are properly set up with their automation system, so every few hours or so it will check our GitHub repo to see if there is a new release. When it finds one, it will put up a PR to create the equivalent Homebrew release, which entails building Codex CLI from source on various versions of macOS. +For Homebrew, we ship Codex as a cask. Homebrew's automation system checks our GitHub repo every few hours for a new release and will open a PR to update the cask with the latest binary. Inevitably, you just have to refresh this page periodically to see if the release has been picked up by their automation system: -https://github.com/Homebrew/homebrew-core/pulls?q=%3Apr+codex +https://github.com/Homebrew/homebrew-cask/pulls?q=%3Apr+codex -Once everything builds, a Homebrew admin has to approve the PR. Again, the whole process takes several hours and we don't have total control over it, but it seems to work pretty well. +For reference, our Homebrew cask lives at: -For reference, our Homebrew formula lives at: - -https://github.com/Homebrew/homebrew-core/blob/main/Formula/c/codex.rb +https://github.com/Homebrew/homebrew-cask/blob/main/Casks/c/codex.rb diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 1aeffce673..df36892337 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -83,6 +83,18 @@ const turn = await thread.run("Summarize repository status", { console.log(turn.finalResponse); ``` +### Attaching images + +Provide structured input entries when you need to include images alongside text. Text entries are concatenated into the final prompt while image entries are passed to the Codex CLI via `--image`. + +```typescript +const turn = await thread.run([ + { type: "text", text: "Describe these screenshots" }, + { type: "local_image", path: "./ui.png" }, + { type: "local_image", path: "./diagram.jpg" }, +]); +``` + ### Resuming an existing thread Threads are persisted in `~/.codex/sessions`. If you lose the in-memory `Thread` object, reconstruct it with `resumeThread()` and keep going. @@ -95,7 +107,7 @@ await thread.run("Implement the fix"); ### Working directory controls -Codex runs in the current working directory by default. To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. +Codex runs in the current working directory by default. To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. ```typescript const thread = codex.startThread({ diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index b1cf4c414a..45ae5fc646 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -11,6 +11,7 @@ export type CodexExecArgs = { baseUrl?: string; apiKey?: string; threadId?: string | null; + images?: string[]; // --model model?: string; // --sandbox @@ -55,6 +56,12 @@ export class CodexExec { commandArgs.push("--output-schema", args.outputSchemaFile); } + if (args.images?.length) { + for (const image of args.images) { + commandArgs.push("--image", image); + } + } + if (args.threadId) { commandArgs.push("resume", args.threadId); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 6e1ace9d14..8e6a71df8c 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -24,7 +24,7 @@ export type { } from "./items"; export { Thread } from "./thread"; -export type { RunResult, RunStreamedResult, Input } from "./thread"; +export type { RunResult, RunStreamedResult, Input, UserInput } from "./thread"; export { Codex } from "./codex"; diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 0caf52b486..2306df25b6 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -25,7 +25,17 @@ export type StreamedTurn = { export type RunStreamedResult = StreamedTurn; /** An input to send to the agent. */ -export type Input = string; +export type UserInput = + | { + type: "text"; + text: string; + } + | { + type: "local_image"; + path: string; + }; + +export type Input = string | UserInput[]; /** Respesent a thread of conversation with the agent. One thread can have multiple consecutive turns. */ export class Thread { @@ -53,21 +63,23 @@ export class Thread { } /** Provides the input to the agent and streams events as they are produced during the turn. */ - async runStreamed(input: string, turnOptions: TurnOptions = {}): Promise { + async runStreamed(input: Input, turnOptions: TurnOptions = {}): Promise { return { events: this.runStreamedInternal(input, turnOptions) }; } private async *runStreamedInternal( - input: string, + input: Input, turnOptions: TurnOptions = {}, ): AsyncGenerator { const { schemaPath, cleanup } = await createOutputSchemaFile(turnOptions.outputSchema); const options = this._threadOptions; + const { prompt, images } = normalizeInput(input); const generator = this._exec.run({ - input, + input: prompt, baseUrl: this._options.baseUrl, apiKey: this._options.apiKey, threadId: this._id, + images, model: options?.model, sandboxMode: options?.sandboxMode, workingDirectory: options?.workingDirectory, @@ -93,7 +105,7 @@ export class Thread { } /** Provides the input to the agent and returns the completed turn. */ - async run(input: string, turnOptions: TurnOptions = {}): Promise { + async run(input: Input, turnOptions: TurnOptions = {}): Promise { const generator = this.runStreamedInternal(input, turnOptions); const items: ThreadItem[] = []; let finalResponse: string = ""; @@ -118,3 +130,19 @@ export class Thread { return { items, finalResponse, usage }; } } + +function normalizeInput(input: Input): { prompt: string; images: string[] } { + if (typeof input === "string") { + return { prompt: input, images: [] }; + } + const promptParts: string[] = []; + const images: string[] = []; + for (const item of input) { + if (item.type === "text") { + promptParts.push(item.text); + } else if (item.type === "local_image") { + images.push(item.path); + } + } + return { prompt: promptParts.join("\n\n"), images }; +} diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index de7ef5555d..f495b1e6ec 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -279,6 +279,82 @@ describe("Codex", () => { await close(); } }); + it("combines structured text input segments", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Combined input applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run([ + { type: "text", text: "Describe file changes" }, + { type: "text", text: "Focus on impacted tests" }, + ]); + + const payload = requests[0]; + expect(payload).toBeDefined(); + const lastUser = payload!.json.input.at(-1); + expect(lastUser?.content?.[0]?.text).toBe("Describe file changes\n\nFocus on impacted tests"); + } finally { + await close(); + } + }); + it("forwards images to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Images applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-images-")); + const imagesDirectoryEntries: [string, string] = [ + path.join(tempDir, "first.png"), + path.join(tempDir, "second.jpg"), + ]; + imagesDirectoryEntries.forEach((image, index) => { + fs.writeFileSync(image, `image-${index}`); + }); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run([ + { type: "text", text: "describe the images" }, + { type: "local_image", path: imagesDirectoryEntries[0] }, + { type: "local_image", path: imagesDirectoryEntries[1] }, + ]); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + const forwardedImages: string[] = []; + for (let i = 0; i < commandArgs!.length; i += 1) { + if (commandArgs![i] === "--image") { + forwardedImages.push(commandArgs![i + 1] ?? ""); + } + } + expect(forwardedImages).toEqual(imagesDirectoryEntries); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + restore(); + await close(); + } + }); it("runs in provided working directory", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200,