From 3c03c25e56e3b6faee2677b5b7c5ff64f086d1b3 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 13 May 2025 16:52:52 -0700 Subject: [PATCH 1/2] feat: introduce --profile for Rust CLI (#921) This introduces a much-needed "profile" concept where users can specify a collection of options under one name and then pass that via `--profile` to the CLI. This PR introduces the `ConfigProfile` struct and makes it a field of `CargoToml`. It further updates `Config::load_from_base_config_with_overrides()` to respect `ConfigProfile`, overriding default values where appropriate. A detailed unit test is added at the end of `config.rs` to verify this behavior. Details on how to use this feature have also been added to `codex-rs/README.md`. --- codex-rs/Cargo.lock | 1 + codex-rs/README.md | 46 ++++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/config.rs | 227 ++++++++++++++++++- codex-rs/core/src/config_profile.rs | 15 ++ codex-rs/core/src/flags.rs | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/mcp_server_config.rs | 2 +- codex-rs/core/src/model_provider_info.rs | 2 +- codex-rs/exec/src/cli.rs | 4 + codex-rs/exec/src/lib.rs | 4 +- codex-rs/mcp-server/src/codex_tool_config.rs | 12 +- codex-rs/tui/src/cli.rs | 4 + codex-rs/tui/src/lib.rs | 3 +- 14 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 codex-rs/core/src/config_profile.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index aa22911ba3..15a6298385 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -531,6 +531,7 @@ dependencies = [ "patch", "path-absolutize", "predicates", + "pretty_assertions", "rand", "reqwest", "seccompiler", diff --git a/codex-rs/README.md b/codex-rs/README.md index 827a565961..fa7244200d 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -109,6 +109,52 @@ approval_policy = "on-failure" approval_policy = "never" ``` +### profiles + +A _profile_ is a collection of configuration values that can be set together. Multiple profiles can be defined in `config.toml` and you can specify the one you +want to use at runtime via the `--profile` flag. + +Here is an example of a `config.toml` that defines multiple profiles: + +```toml +model = "o3" +approval_policy = "unless-allow-listed" +sandbox_permissions = ["disk-full-read-access"] +disable_response_storage = false + +# Setting `profile` is equivalent to specifying `--profile o3` on the command +# line, though the `--profile` flag can still be used to override this value. +profile = "o3" + +[model_providers.openai-chat-completions] +name = "OpenAI using Chat Completions" +base_url = "https://api.openai.com/v1" +env_key = "OPENAI_API_KEY" +wire_api = "chat" + +[profiles.o3] +model = "o3" +model_provider = "openai" +approval_policy = "never" + +[profiles.gpt3] +model = "gpt-3.5-turbo" +model_provider = "openai-chat-completions" + +[profiles.zdr] +model = "o3" +model_provider = "openai" +approval_policy = "on-failure" +disable_response_storage = true +``` + +Users can specify config values at multiple levels. Order of precedence is as follows: + +1. custom command-line argument, e.g., `--model o3` +2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself) +3. as an entry in `config.toml`, e.g., `model = "o3"` +4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `o4-mini`) + ### sandbox_permissions List of permissions to grant to the sandbox that Codex uses to execute untrusted commands: diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index c04bcb6a55..6154d91d0c 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -58,5 +58,6 @@ openssl-sys = { version = "*", features = ["vendored"] } [dev-dependencies] assert_cmd = "2" predicates = "3" +pretty_assertions = "1.4.1" tempfile = "3" wiremock = "0.6" diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 4c815ad047..42c1684ac0 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,3 +1,4 @@ +use crate::config_profile::ConfigProfile; use crate::flags::OPENAI_DEFAULT_MODEL; use crate::mcp_server_config::McpServerConfig; use crate::model_provider_info::ModelProviderInfo; @@ -8,6 +9,7 @@ use crate::protocol::SandboxPolicy; use dirs::home_dir; use serde::Deserialize; use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; /// Maximum number of bytes of the documentation that will be embedded. Larger @@ -16,7 +18,7 @@ use std::path::PathBuf; pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB /// Application configuration loaded from disk and merged with overrides. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Config { /// Optional override of model selection. pub model: String, @@ -117,6 +119,13 @@ pub struct ConfigToml { /// Maximum number of bytes to include from an AGENTS.md project doc file. pub project_doc_max_bytes: Option, + + /// Profile to use from the `profiles` map. + pub profile: Option, + + /// Named profiles to facilitate switching between different configurations. + #[serde(default)] + pub profiles: HashMap, } impl ConfigToml { @@ -176,7 +185,8 @@ pub struct ConfigOverrides { pub approval_policy: Option, pub sandbox_policy: Option, pub disable_response_storage: Option, - pub provider: Option, + pub model_provider: Option, + pub config_profile: Option, } impl Config { @@ -186,14 +196,16 @@ impl Config { pub fn load_with_overrides(overrides: ConfigOverrides) -> std::io::Result { let cfg: ConfigToml = ConfigToml::load_from_toml()?; tracing::warn!("Config parsed from config.toml: {cfg:?}"); - Self::load_from_base_config_with_overrides(cfg, overrides) + let codex_dir = codex_dir().ok(); + Self::load_from_base_config_with_overrides(cfg, overrides, codex_dir.as_deref()) } fn load_from_base_config_with_overrides( cfg: ConfigToml, overrides: ConfigOverrides, + codex_dir: Option<&Path>, ) -> std::io::Result { - let instructions = Self::load_instructions(); + let instructions = Self::load_instructions(codex_dir); // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { @@ -202,9 +214,24 @@ impl Config { approval_policy, sandbox_policy, disable_response_storage, - provider, + model_provider, + config_profile: config_profile_key, } = overrides; + let config_profile = match config_profile_key.or(cfg.profile) { + Some(key) => cfg + .profiles + .get(&key) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("config profile `{key}` not found"), + ) + })? + .clone(), + None => ConfigProfile::default(), + }; + let sandbox_policy = match sandbox_policy { Some(sandbox_policy) => sandbox_policy, None => { @@ -226,7 +253,8 @@ impl Config { model_providers.entry(key).or_insert(provider); } - let model_provider_id = provider + let model_provider_id = model_provider + .or(config_profile.model_provider) .or(cfg.model_provider) .unwrap_or_else(|| "openai".to_string()); let model_provider = model_providers @@ -259,15 +287,20 @@ impl Config { }; let config = Self { - model: model.or(cfg.model).unwrap_or_else(default_model), + model: model + .or(config_profile.model) + .or(cfg.model) + .unwrap_or_else(default_model), model_provider_id, model_provider, cwd: resolved_cwd, approval_policy: approval_policy + .or(config_profile.approval_policy) .or(cfg.approval_policy) .unwrap_or_else(AskForApproval::default), sandbox_policy, disable_response_storage: disable_response_storage + .or(config_profile.disable_response_storage) .or(cfg.disable_response_storage) .unwrap_or(false), notify: cfg.notify, @@ -279,8 +312,12 @@ impl Config { Ok(config) } - fn load_instructions() -> Option { - let mut p = codex_dir().ok()?; + fn load_instructions(codex_dir: Option<&Path>) -> Option { + let mut p = match codex_dir { + Some(p) => p.to_path_buf(), + None => return None, + }; + p.push("instructions.md"); std::fs::read_to_string(&p).ok().and_then(|s| { let s = s.trim(); @@ -299,6 +336,7 @@ impl Config { Self::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), + None, ) .expect("defaults for test should always succeed") } @@ -377,6 +415,8 @@ pub fn parse_sandbox_permission_with_base_path( mod tests { #![allow(clippy::expect_used, clippy::unwrap_used)] use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; /// Verify that the `sandbox_permissions` field on `ConfigToml` correctly /// differentiates between a value that is completely absent in the @@ -429,4 +469,173 @@ mod tests { let msg = err.to_string(); assert!(msg.contains("not-a-real-permission")); } + + /// Users can specify config values at multiple levels that have the + /// following precedence: + /// + /// 1. custom command-line argument, e.g. `--model o3` + /// 2. as part of a profile, where the `--profile` is specified via a CLI + /// (or in the config file itelf) + /// 3. as an entry in `config.toml`, e.g. `model = "o3"` + /// 4. the default value for a required field defined in code, e.g., + /// `crate::flags::OPENAI_DEFAULT_MODEL` + /// + /// Note that profiles are the recommended way to specify a group of + /// configuration options together. + #[test] + fn test_precedence_overrides_then_profile_then_config_toml() -> std::io::Result<()> { + let toml = r#" +model = "o3" +approval_policy = "unless-allow-listed" +sandbox_permissions = ["disk-full-read-access"] +disable_response_storage = false + +# Can be used to determine which profile to use if not specified by +# `ConfigOverrides`. +profile = "gpt3" + +[model_providers.openai-chat-completions] +name = "OpenAI using Chat Completions" +base_url = "https://api.openai.com/v1" +env_key = "OPENAI_API_KEY" +wire_api = "chat" + +[profiles.o3] +model = "o3" +model_provider = "openai" +approval_policy = "never" + +[profiles.gpt3] +model = "gpt-3.5-turbo" +model_provider = "openai-chat-completions" + +[profiles.zdr] +model = "o3" +model_provider = "openai" +approval_policy = "on-failure" +disable_response_storage = true +"#; + + let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed"); + + // Use a temporary directory for the cwd so it does not contain an + // AGENTS.md file. + let cwd_temp_dir = TempDir::new().unwrap(); + let cwd = cwd_temp_dir.path().to_path_buf(); + // Make it look like a Git repo so it does not search for AGENTS.md in + // a parent folder, either. + std::fs::write(cwd.join(".git"), "gitdir: nowhere")?; + + let openai_chat_completions_provider = ModelProviderInfo { + name: "OpenAI using Chat Completions".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + env_key: Some("OPENAI_API_KEY".to_string()), + wire_api: crate::WireApi::Chat, + env_key_instructions: None, + }; + let model_provider_map = { + let mut model_provider_map = built_in_model_providers(); + model_provider_map.insert( + "openai-chat-completions".to_string(), + openai_chat_completions_provider.clone(), + ); + model_provider_map + }; + + let openai_provider = model_provider_map + .get("openai") + .expect("openai provider should exist") + .clone(); + + let o3_profile_overrides = ConfigOverrides { + config_profile: Some("o3".to_string()), + cwd: Some(cwd.clone()), + ..Default::default() + }; + let o3_profile_config = + Config::load_from_base_config_with_overrides(cfg.clone(), o3_profile_overrides, None)?; + assert_eq!( + Config { + model: "o3".to_string(), + model_provider_id: "openai".to_string(), + model_provider: openai_provider.clone(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + disable_response_storage: false, + instructions: None, + notify: None, + cwd: cwd.clone(), + mcp_servers: HashMap::new(), + model_providers: model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + }, + o3_profile_config + ); + + let gpt3_profile_overrides = ConfigOverrides { + config_profile: Some("gpt3".to_string()), + cwd: Some(cwd.clone()), + ..Default::default() + }; + let gpt3_profile_config = Config::load_from_base_config_with_overrides( + cfg.clone(), + gpt3_profile_overrides, + None, + )?; + let expected_gpt3_profile_config = Config { + model: "gpt-3.5-turbo".to_string(), + model_provider_id: "openai-chat-completions".to_string(), + model_provider: openai_chat_completions_provider, + approval_policy: AskForApproval::UnlessAllowListed, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + disable_response_storage: false, + instructions: None, + notify: None, + cwd: cwd.clone(), + mcp_servers: HashMap::new(), + model_providers: model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + }; + assert_eq!(expected_gpt3_profile_config.clone(), gpt3_profile_config); + + // Verify that loading without specifying a profile in ConfigOverrides + // uses the default profile from the config file. + let default_profile_overrides = ConfigOverrides { + cwd: Some(cwd.clone()), + ..Default::default() + }; + let default_profile_config = Config::load_from_base_config_with_overrides( + cfg.clone(), + default_profile_overrides, + None, + )?; + assert_eq!(expected_gpt3_profile_config, default_profile_config); + + let zdr_profile_overrides = ConfigOverrides { + config_profile: Some("zdr".to_string()), + cwd: Some(cwd.clone()), + ..Default::default() + }; + let zdr_profile_config = + Config::load_from_base_config_with_overrides(cfg.clone(), zdr_profile_overrides, None)?; + assert_eq!( + Config { + model: "o3".to_string(), + model_provider_id: "openai".to_string(), + model_provider: openai_provider.clone(), + approval_policy: AskForApproval::OnFailure, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + disable_response_storage: true, + instructions: None, + notify: None, + cwd: cwd.clone(), + mcp_servers: HashMap::new(), + model_providers: model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + }, + zdr_profile_config + ); + + Ok(()) + } } diff --git a/codex-rs/core/src/config_profile.rs b/codex-rs/core/src/config_profile.rs new file mode 100644 index 0000000000..98d73bb5ab --- /dev/null +++ b/codex-rs/core/src/config_profile.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; + +use crate::protocol::AskForApproval; + +/// Collection of common configuration options that a user can define as a unit +/// in `config.toml`. +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +pub struct ConfigProfile { + pub model: Option, + /// The key in the `model_providers` map identifying the + /// [`ModelProviderInfo`] to use. + pub model_provider: Option, + pub approval_policy: Option, + pub disable_response_storage: Option, +} diff --git a/codex-rs/core/src/flags.rs b/codex-rs/core/src/flags.rs index 44198fdee5..e8cc973c99 100644 --- a/codex-rs/core/src/flags.rs +++ b/codex-rs/core/src/flags.rs @@ -3,7 +3,7 @@ use std::time::Duration; use env_flags::env_flags; env_flags! { - pub OPENAI_DEFAULT_MODEL: &str = "o3"; + pub OPENAI_DEFAULT_MODEL: &str = "o4-mini"; pub OPENAI_API_BASE: &str = "https://api.openai.com/v1"; /// Fallback when the provider-specific key is not set. diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 43c97a8736..c4f380269f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod codex; pub use codex::Codex; pub mod codex_wrapper; pub mod config; +pub mod config_profile; mod conversation_history; pub mod error; pub mod exec; diff --git a/codex-rs/core/src/mcp_server_config.rs b/codex-rs/core/src/mcp_server_config.rs index 261a75d13e..30845431fa 100644 --- a/codex-rs/core/src/mcp_server_config.rs +++ b/codex-rs/core/src/mcp_server_config.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::Deserialize; -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone, PartialEq)] pub struct McpServerConfig { pub command: String, diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 969797cb61..186e28d344 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -29,7 +29,7 @@ pub enum WireApi { } /// Serializable representation of a provider definition. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct ModelProviderInfo { /// Friendly display name. pub name: String, diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 1248ef3b19..dd72b3e956 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -14,6 +14,10 @@ pub struct Cli { #[arg(long, short = 'm')] pub model: Option, + /// Configuration profile from config.toml to specify default options. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR) #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d711388f35..348bff08e6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -25,6 +25,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { let Cli { images, model, + config_profile, full_auto, sandbox, cwd, @@ -52,6 +53,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { // Load configuration and determine approval policy let overrides = ConfigOverrides { model, + config_profile, // This CLI is intended to be headless and has no affordances for asking // the user for approval. approval_policy: Some(AskForApproval::Never), @@ -62,7 +64,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { None }, cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)), - provider: None, + model_provider: None, }; let config = Config::load_with_overrides(overrides)?; diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 780807952c..2ddc00fbf9 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -22,6 +22,10 @@ pub(crate) struct CodexToolCallParam { #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + /// Configuration profile from config.toml to specify default options. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + /// Working directory for the session. If relative, it is resolved against /// the server process's current working directory. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -144,6 +148,7 @@ impl CodexToolCallParam { let Self { prompt, model, + profile, cwd, approval_policy, sandbox_permissions, @@ -156,11 +161,12 @@ impl CodexToolCallParam { // Build ConfigOverrides recognised by codex-core. let overrides = codex_core::config::ConfigOverrides { model, + config_profile: profile, cwd: cwd.map(PathBuf::from), approval_policy: approval_policy.map(Into::into), sandbox_policy, disable_response_storage, - provider: None, + model_provider: None, }; let cfg = codex_core::config::Config::load_with_overrides(overrides)?; @@ -218,6 +224,10 @@ mod tests { "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\")", "type": "string" }, + "profile": { + "description": "Configuration profile from config.toml to specify default options.", + "type": "string" + }, "prompt": { "description": "The *initial user prompt* to start the Codex conversation.", "type": "string" diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index c260caa9f4..f077d26743 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -17,6 +17,10 @@ pub struct Cli { #[arg(long, short = 'm')] pub model: Option, + /// Configuration profile from config.toml to specify default options. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Configure when the model requires human approval before executing a command. #[arg(long = "ask-for-approval", short = 'a')] pub approval_policy: Option, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index fe4f995432..e0b6274c7d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -55,7 +55,8 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> { None }, cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)), - provider: None, + model_provider: None, + config_profile: cli.config_profile.clone(), }; #[allow(clippy::print_stderr)] match Config::load_with_overrides(overrides) { From 16be257bcc2d474e25779f8d6c44d65ddc37f752 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 13 May 2025 17:35:24 -0700 Subject: [PATCH 2/2] fix: use local timestamps in log files instead of UTC --- codex-rs/Cargo.lock | 13 ++++ codex-rs/core/Cargo.toml | 4 +- codex-rs/core/src/codex.rs | 20 +++-- codex-rs/core/src/protocol.rs | 15 +++- codex-rs/core/src/rollout.rs | 21 ++--- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/chatwidget.rs | 12 +-- .../tui/src/conversation_history_widget.rs | 14 ++-- codex-rs/tui/src/history_cell.rs | 78 ++++++++++--------- 9 files changed, 101 insertions(+), 77 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 15a6298385..d67a2df70a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -639,6 +639,7 @@ dependencies = [ "tui-input", "tui-markdown", "tui-textarea", + "uuid", ] [[package]] @@ -2275,6 +2276,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.32.2" @@ -3686,7 +3696,9 @@ checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", @@ -4097,6 +4109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.2", + "serde", ] [[package]] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 6154d91d0c..e7a93d3dea 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -31,7 +31,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2.0.12" -time = { version = "0.3", features = ["formatting", "macros"] } +time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } tokio = { version = "1", features = [ "io-std", "macros", @@ -44,7 +44,7 @@ toml = "0.8.20" tracing = { version = "0.1.41", features = ["log"] } tree-sitter = "0.25.3" tree-sitter-bash = "0.23.3" -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["serde", "v4"] } [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2.172" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 82296ccb5d..26e1f665bf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -30,6 +30,7 @@ use tracing::error; use tracing::info; use tracing::trace; use tracing::warn; +use uuid::Uuid; use crate::WireApi; use crate::client::ModelClient; @@ -62,6 +63,7 @@ use crate::protocol::InputItem; use crate::protocol::Op; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; +use crate::protocol::SessionConfiguredEvent; use crate::protocol::Submission; use crate::rollout::RolloutRecorder; use crate::safety::SafetyCheck; @@ -596,13 +598,15 @@ async fn submission_loop( // Attempt to create a RolloutRecorder *before* moving the // `instructions` value into the Session struct. - let rollout_recorder = match RolloutRecorder::new(instructions.clone()).await { - Ok(r) => Some(r), - Err(e) => { - tracing::warn!("failed to initialise rollout recorder: {e}"); - None - } - }; + let session_id = Uuid::new_v4(); + let rollout_recorder = + match RolloutRecorder::new(session_id, instructions.clone()).await { + Ok(r) => Some(r), + Err(e) => { + tracing::warn!("failed to initialise rollout recorder: {e}"); + None + } + }; sess = Some(Arc::new(Session { client, @@ -622,7 +626,7 @@ async fn submission_loop( // ack let events = std::iter::once(Event { id: sub.id.clone(), - msg: EventMsg::SessionConfigured { model }, + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, model }), }) .chain(mcp_connection_errors.into_iter()); for event in events { diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 1069a90499..e4b8382635 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; use mcp_types::CallToolResult; use serde::Deserialize; use serde::Serialize; +use uuid::Uuid; use crate::model_provider_info::ModelProviderInfo; @@ -323,10 +324,7 @@ pub enum EventMsg { }, /// Ack the client's configure message. - SessionConfigured { - /// Tell the client what model is being queried. - model: String, - }, + SessionConfigured(SessionConfiguredEvent), McpToolCallBegin { /// Identifier so this can be paired with the McpToolCallEnd event. @@ -429,6 +427,15 @@ pub enum EventMsg { }, } +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct SessionConfiguredEvent { + /// Unique id for this session. + pub session_id: Uuid, + + /// Tell the client what model is being queried. + pub model: String, +} + /// User's decision in response to an ExecApprovalRequest. #[derive(Debug, Default, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 2a45222a4e..7a014f401c 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -37,8 +37,8 @@ struct SessionMeta { /// Rollouts are recorded as JSONL and can be inspected with tools such as: /// /// ```ignore -/// $ jq -C . ~/.codex/sessions/rollout-2025-05-07-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl -/// $ fx ~/.codex/sessions/rollout-2025-05-07-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl +/// $ jq -C . ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl +/// $ fx ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl /// ``` #[derive(Clone)] pub(crate) struct RolloutRecorder { @@ -49,12 +49,12 @@ impl RolloutRecorder { /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory /// cannot be created or the rollout file cannot be opened we return the /// error so the caller can decide whether to disable persistence. - pub async fn new(instructions: Option) -> std::io::Result { + pub async fn new(uuid: Uuid, instructions: Option) -> std::io::Result { let LogFileInfo { file, session_id, timestamp, - } = create_log_file()?; + } = create_log_file(uuid)?; // Build the static session metadata JSON first. let timestamp_format: &[FormatItem] = format_description!( @@ -154,18 +154,19 @@ struct LogFileInfo { timestamp: OffsetDateTime, } -fn create_log_file() -> std::io::Result { +fn create_log_file(session_id: Uuid) -> std::io::Result { // Resolve ~/.codex/sessions and create it if missing. let mut dir = codex_dir()?; dir.push(SESSIONS_SUBDIR); fs::create_dir_all(&dir)?; - // Generate a v4 UUID – matches the JS CLI implementation. - let session_id = Uuid::new_v4(); - let timestamp = OffsetDateTime::now_utc(); + let timestamp = OffsetDateTime::now_local() + .map_err(|e| IoError::new(ErrorKind::Other, format!("failed to get local time: {e}")))?; - // Custom format for YYYY-MM-DD. - let format: &[FormatItem] = format_description!("[year]-[month]-[day]"); + // Custom format for YYYY-MM-DDThh-mm-ss. Use `-` instead of `:` for + // compatibility with filesystems that do not allow colons in filenames. + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); let date_str = timestamp .format(format) .map_err(|e| IoError::new(ErrorKind::Other, format!("failed to format timestamp: {e}")))?; diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 230cbd2b17..4bd23015e9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,3 +42,4 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tui-input = "0.11.1" tui-markdown = "0.3.3" tui-textarea = "0.7.0" +uuid = { version = "1" } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c9a04b7b0a..accb73053c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -102,8 +102,6 @@ impl ChatWidget<'_> { config, }; - let _ = chat_widget.submit_welcome_message(); - if initial_prompt.is_some() || !initial_images.is_empty() { let text = initial_prompt.unwrap_or_default(); let _ = chat_widget.submit_user_message_with_images(text, initial_images); @@ -161,12 +159,6 @@ impl ChatWidget<'_> { } } - fn submit_welcome_message(&mut self) -> std::result::Result<(), SendError> { - self.conversation_history.add_welcome_message(&self.config); - self.request_redraw()?; - Ok(()) - } - fn submit_user_message( &mut self, text: String, @@ -215,10 +207,10 @@ impl ChatWidget<'_> { ) -> std::result::Result<(), SendError> { let Event { id, msg } = event; match msg { - EventMsg::SessionConfigured { model } => { + EventMsg::SessionConfigured(event) => { // Record session information at the top of the conversation. self.conversation_history - .add_session_info(&self.config, model); + .add_session_info(&self.config, event); self.request_redraw()?; } EventMsg::AgentMessage { message } => { diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index 70e7b6c46e..f7a9405954 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -3,6 +3,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use codex_core::config::Config; use codex_core::protocol::FileChange; +use codex_core::protocol::SessionConfiguredEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::prelude::*; @@ -162,8 +163,11 @@ impl ConversationHistoryWidget { self.scroll_position = usize::MAX; } - pub fn add_welcome_message(&mut self, config: &Config) { - self.add_to_history(HistoryCell::new_welcome_message(config)); + /// Note `model` could differ from `config.model` if the agent decided to + /// use a different model than the one requested by the user. + pub fn add_session_info(&mut self, config: &Config, event: SessionConfiguredEvent) { + let is_first_event = self.history.is_empty(); + self.add_to_history(HistoryCell::new_session_info(config, event, is_first_event)); } pub fn add_user_message(&mut self, message: String) { @@ -195,12 +199,6 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_patch_event(event_type, changes)); } - /// Note `model` could differ from `config.model` if the agent decided to - /// use a different model than the one requested by the user. - pub fn add_session_info(&mut self, config: &Config, model: String) { - self.add_to_history(HistoryCell::new_session_info(config, model)); - } - pub fn add_active_exec_command(&mut self, call_id: String, command: Vec) { self.add_to_history(HistoryCell::new_active_exec_command(call_id, command)); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4f4259aaa6..23ce66679b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2,6 +2,7 @@ use codex_ansi_escape::ansi_escape_line; use codex_common::elapsed::format_duration; use codex_core::config::Config; use codex_core::protocol::FileChange; +use codex_core::protocol::SessionConfiguredEvent; use ratatui::prelude::*; use ratatui::style::Color; use ratatui::style::Modifier; @@ -94,29 +95,50 @@ pub(crate) enum HistoryCell { const TOOL_CALL_MAX_LINES: usize = 5; impl HistoryCell { - pub(crate) fn new_welcome_message(config: &Config) -> Self { - let mut lines: Vec> = vec![ - Line::from(vec![ - "OpenAI ".into(), - "Codex".bold(), - " (research preview)".dim(), - ]), - Line::from(""), - Line::from("codex session:".magenta().bold()), - ]; + pub(crate) fn new_session_info( + config: &Config, + event: SessionConfiguredEvent, + is_first_event: bool, + ) -> Self { + let SessionConfiguredEvent { model, session_id } = event; + if is_first_event { + let mut lines: Vec> = vec![ + Line::from(vec![ + "OpenAI ".into(), + "Codex".bold(), + " (research preview)".dim(), + ]), + Line::from(""), + Line::from(vec![ + "codex session".magenta().bold(), + " ".into(), + session_id.to_string().dim(), + ]), + ]; - let entries = vec![ - ("workdir", config.cwd.display().to_string()), - ("model", config.model.clone()), - ("provider", config.model_provider_id.clone()), - ("approval", format!("{:?}", config.approval_policy)), - ("sandbox", format!("{:?}", config.sandbox_policy)), - ]; - for (key, value) in entries { - lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()])); + let entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", config.model.clone()), + ("provider", config.model_provider_id.clone()), + ("approval", format!("{:?}", config.approval_policy)), + ("sandbox", format!("{:?}", config.sandbox_policy)), + ]; + for (key, value) in entries { + lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()])); + } + lines.push(Line::from("")); + HistoryCell::WelcomeMessage { lines } + } else if config.model == model { + HistoryCell::SessionInfo { lines: vec![] } + } else { + let lines = vec![ + Line::from("model changed:".magenta().bold()), + Line::from(format!("requested: {}", config.model)), + Line::from(format!("used: {}", model)), + Line::from(""), + ]; + HistoryCell::SessionInfo { lines } } - lines.push(Line::from("")); - HistoryCell::WelcomeMessage { lines } } pub(crate) fn new_user_prompt(message: String) -> Self { @@ -296,20 +318,6 @@ impl HistoryCell { HistoryCell::ErrorEvent { lines } } - pub(crate) fn new_session_info(config: &Config, model: String) -> Self { - if config.model == model { - HistoryCell::SessionInfo { lines: vec![] } - } else { - let lines = vec![ - Line::from("model changed:".magenta().bold()), - Line::from(format!("requested: {}", config.model)), - Line::from(format!("used: {}", model)), - Line::from(""), - ]; - HistoryCell::SessionInfo { lines } - } - } - /// Create a new `PendingPatch` cell that lists the file‑level summary of /// a proposed patch. The summary lines should already be formatted (e.g. /// "A path/to/file.rs").