diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4f815078c2..056eae9576 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1726,6 +1726,7 @@ dependencies = [ "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", + "csv", "ctor 0.6.3", "dirs", "dunce", @@ -2531,7 +2532,9 @@ name = "codex-utils-sleep-inhibitor" version = "0.0.0" dependencies = [ "core-foundation 0.9.4", + "libc", "tracing", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index baed07814d..b3bb85d5aa 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -160,6 +160,7 @@ clap = "4" clap_complete = "4" color-eyre = "0.6.3" crossbeam-channel = "0.5.15" +csv = "1.3.1" crossterm = "0.28.1" ctor = "0.6.3" derive_more = "2" diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9e53acd636..09b3da4ce6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -168,6 +168,7 @@ use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_app_server_protocol::build_turns_from_rollout_items; +use codex_arg0::Arg0DispatchPaths; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_cloud_requirements::cloud_requirements_loader; @@ -337,7 +338,7 @@ pub(crate) struct CodexMessageProcessor { auth_manager: Arc, thread_manager: Arc, outgoing: Arc, - codex_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, config: Arc, single_client_mode: bool, cli_overrides: Vec<(String, TomlValue)>, @@ -361,7 +362,7 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) auth_manager: Arc, pub(crate) thread_manager: Arc, pub(crate) outgoing: Arc, - pub(crate) codex_linux_sandbox_exe: Option, + pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) cloud_requirements: Arc>, @@ -398,7 +399,7 @@ impl CodexMessageProcessor { auth_manager, thread_manager, outgoing, - codex_linux_sandbox_exe, + arg0_paths, config, cli_overrides, cloud_requirements, @@ -409,7 +410,7 @@ impl CodexMessageProcessor { auth_manager, thread_manager, outgoing: outgoing.clone(), - codex_linux_sandbox_exe, + arg0_paths, config, single_client_mode, cli_overrides, @@ -425,7 +426,7 @@ impl CodexMessageProcessor { async fn load_latest_config(&self) -> Result { let cloud_requirements = self.current_cloud_requirements(); - codex_core::config::ConfigBuilder::default() + let mut config = codex_core::config::ConfigBuilder::default() .cli_overrides(self.cli_overrides.clone()) .cloud_requirements(cloud_requirements) .build() @@ -434,7 +435,10 @@ impl CodexMessageProcessor { code: INTERNAL_ERROR_CODE, message: format!("failed to reload config: {err}"), data: None, - }) + })?; + config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); + config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone(); + Ok(config) } fn current_cloud_requirements(&self) -> CloudRequirementsLoader { @@ -1758,8 +1762,10 @@ impl CodexMessageProcessor { None => None, }; let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let command = params.command; let exec_params = ExecParams { - command: params.command, + original_command: command.join(" "), + command, cwd, expiration: timeout_ms.into(), env, @@ -1789,7 +1795,7 @@ impl CodexMessageProcessor { None => self.config.permissions.sandbox_policy.get().clone(), }; - let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone(); + let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); let request_for_task = request; let sandbox_cwd = self.config.cwd.clone(); @@ -1854,7 +1860,8 @@ impl CodexMessageProcessor { approval_policy, sandbox_mode, model_provider, - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, developer_instructions, compact_prompt, @@ -2108,7 +2115,8 @@ impl CodexMessageProcessor { approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), sandbox_mode: sandbox.map(SandboxMode::to_core), - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, developer_instructions, personality, @@ -4318,7 +4326,8 @@ impl CodexMessageProcessor { approval_policy, sandbox_mode, model_provider, - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, developer_instructions, compact_prompt, @@ -4329,7 +4338,8 @@ impl CodexMessageProcessor { } None => ( ConfigOverrides { - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() }, None, @@ -4513,7 +4523,8 @@ impl CodexMessageProcessor { approval_policy, sandbox_mode, model_provider, - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, developer_instructions, compact_prompt, @@ -4525,7 +4536,8 @@ impl CodexMessageProcessor { } None => ( ConfigOverrides { - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() }, None, @@ -5897,6 +5909,11 @@ impl CodexMessageProcessor { EventMsg::TurnComplete(_) => "task_complete", _ => &event.msg.to_string(), }; + let request_event_name = format!("codex/event/{event_formatted}"); + tracing::trace!( + conversation_id = %conversation_id, + "app-server event: {request_event_name}" + ); let mut params = match serde_json::to_value(event.clone()) { Ok(serde_json::Value::Object(map)) => map, Ok(_) => { @@ -5931,7 +5948,7 @@ impl CodexMessageProcessor { .send_notification_to_connections( &subscribed_connection_ids, OutgoingNotification { - method: format!("codex/event/{event_formatted}"), + method: request_event_name, params: Some(params.into()), }, ) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 1bdbf97a13..5122a48e82 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -1,5 +1,6 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] +use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader; use codex_core::AuthManager; use codex_core::config::Config; @@ -12,7 +13,6 @@ use std::collections::HashMap; use std::collections::HashSet; use std::io::ErrorKind; use std::io::Result as IoResult; -use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; @@ -225,13 +225,13 @@ fn log_format_from_env() -> LogFormat { } pub async fn run_main( - codex_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, loader_overrides: LoaderOverrides, default_analytics_enabled: bool, ) -> IoResult<()> { run_main_with_transport( - codex_linux_sandbox_exe, + arg0_paths, cli_config_overrides, loader_overrides, default_analytics_enabled, @@ -241,7 +241,7 @@ pub async fn run_main( } pub async fn run_main_with_transport( - codex_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, loader_overrides: LoaderOverrides, default_analytics_enabled: bool, @@ -454,7 +454,7 @@ pub async fn run_main_with_transport( let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, - codex_linux_sandbox_exe, + arg0_paths, config: Arc::new(config), single_client_mode, cli_overrides, diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 5c4e5eacc7..5c28413e88 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -1,6 +1,7 @@ use clap::Parser; use codex_app_server::AppServerTransport; use codex_app_server::run_main_with_transport; +use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_core::config_loader::LoaderOverrides; use codex_utils_cli::CliConfigOverrides; @@ -23,7 +24,7 @@ struct AppServerArgs { } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); let managed_config_path = managed_config_path_from_debug_env(); let loader_overrides = LoaderOverrides { @@ -33,7 +34,7 @@ fn main() -> anyhow::Result<()> { let transport = args.listen; run_main_with_transport( - codex_linux_sandbox_exe, + arg0_paths, CliConfigOverrides::default(), loader_overrides, false, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index de4418f0e9..214e584c35 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,5 +1,4 @@ use std::collections::HashSet; -use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; @@ -32,6 +31,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; +use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; use codex_core::ThreadManager; use codex_core::auth::ExternalAuthRefreshContext; @@ -138,7 +138,7 @@ pub(crate) struct ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, - pub(crate) codex_linux_sandbox_exe: Option, + pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) single_client_mode: bool, pub(crate) cli_overrides: Vec<(String, TomlValue)>, @@ -154,7 +154,7 @@ impl MessageProcessor { pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, - codex_linux_sandbox_exe, + arg0_paths, config, single_client_mode, cli_overrides, @@ -183,7 +183,7 @@ impl MessageProcessor { auth_manager, thread_manager, outgoing: outgoing.clone(), - codex_linux_sandbox_exe, + arg0_paths, config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), cloud_requirements: cloud_requirements.clone(), @@ -213,6 +213,12 @@ impl MessageProcessor { session: &mut ConnectionSessionState, outbound_initialized: &AtomicBool, ) { + let request_method = request.method.as_str(); + tracing::trace!( + ?connection_id, + request_id = ?request.id, + "app-server request: {request_method}" + ); let request_id = ConnectionRequestId { connection_id, request_id: request.id.clone(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 810182db0a..fe8f755883 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -275,6 +275,10 @@ impl OutgoingMessageSender { connection_ids: &[ConnectionId], notification: ServerNotification, ) { + tracing::trace!( + targeted_connections = connection_ids.len(), + "app-server event: {notification}" + ); let outgoing_message = OutgoingMessage::AppServerNotification(notification); if connection_ids.is_empty() { if let Err(err) = self diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 4e48f909b3..bb35c2c0ab 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -105,6 +105,7 @@ impl McpProcess { cmd.stdin(Stdio::piped()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); + cmd.current_dir(codex_home); cmd.env("CODEX_HOME", codex_home); cmd.env("RUST_LOG", "debug"); cmd.env_remove(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index fdcbaca5b0..58deb5f82e 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::ExperimentalFeatureListResponse; use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_core::config::ConfigBuilder; use codex_core::features::FEATURES; use codex_core::features::Stage; use pretty_assertions::assert_eq; @@ -20,6 +21,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Result<()> { let codex_home = TempDir::new()?; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -63,7 +69,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu display_name, description, announcement, - enabled: spec.default_enabled, + enabled: config.features.enabled(spec.id), default_enabled: spec.default_enabled, } }) diff --git a/codex-rs/app-server/tests/suite/v2/skill_approval.rs b/codex-rs/app-server/tests/suite/v2/skill_approval.rs index 945a545a8d..a86e459768 100644 --- a/codex-rs/app-server/tests/suite/v2/skill_approval.rs +++ b/codex-rs/app-server/tests/suite/v2/skill_approval.rs @@ -3,6 +3,7 @@ use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; @@ -11,19 +12,72 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::features::Feature; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +fn write_skill_with_script( + home: &Path, + name: &str, + script_body: &str, +) -> Result { + let skill_dir = home.join("skills").join(name); + let scripts_dir = skill_dir.join("scripts"); + fs::create_dir_all(&scripts_dir)?; + fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {name} skill\n---\n"), + )?; + let script_path = scripts_dir.join("run.py"); + fs::write(&script_path, script_body)?; + Ok(script_path) +} + +fn shell_command_response(tool_call_id: &str, command: &str) -> Result { + let arguments = serde_json::to_string(&json!({ + "command": command, + "timeout_ms": 500, + }))?; + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(tool_call_id, "shell_command", &arguments), + responses::ev_completed("resp-1"), + ])) +} + +fn command_for_script(script_path: &Path) -> Result { + let runner = if cfg!(windows) { "python" } else { "python3" }; + let script_path = script_path.to_string_lossy().into_owned(); + Ok(shlex::try_join([runner, script_path.as_str()])?) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn skill_request_approval_round_trip() -> Result<()> { +async fn skill_request_approval_round_trip_on_shell_command_skill_script_exec() -> Result<()> { let codex_home = tempfile::TempDir::new()?; - let server = - create_mock_responses_server_sequence(vec![create_final_assistant_message_sse_response( - "done", - )?]) - .await; - create_config_toml(codex_home.path(), &server.uri())?; + let script_path = write_skill_with_script(codex_home.path(), "demo", "print('hello')")?; + let tool_call_id = "skill-call"; + let command = command_for_script(&script_path)?; + let server = create_mock_responses_server_sequence(vec![ + shell_command_response(tool_call_id, &command)?, + create_final_assistant_message_sse_response("done")?, + ]) + .await; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::from([(Feature::SkillApproval, true)]), + 8192, + Some(false), + "mock_provider", + "compact", + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -57,7 +111,7 @@ async fn skill_request_approval_round_trip() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), ) .await??; - let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + let TurnStartResponse { .. } = to_response::(turn_start_resp)?; let server_req = timeout( DEFAULT_READ_TIMEOUT, @@ -68,8 +122,8 @@ async fn skill_request_approval_round_trip() -> Result<()> { panic!("expected SkillRequestApproval request, got: {server_req:?}"); }; - assert_eq!(params.item_id, turn.id); - assert_eq!(params.skill_name, "test-skill"); + assert_eq!(params.item_id, tool_call_id); + assert_eq!(params.skill_name, "demo"); mcp.send_response(request_id, serde_json::json!({ "decision": "approve" })) .await?; @@ -82,28 +136,3 @@ async fn skill_request_approval_round_trip() -> Result<()> { Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "read-only" - -model_provider = "mock_provider" - -[features] -skill_approval = true - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index fca681d5ea..cee651810b 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -17,19 +17,31 @@ const EXECVE_WRAPPER_ARG0: &str = "codex-execve-wrapper"; const LOCK_FILENAME: &str = ".lock"; const TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Arg0DispatchPaths { + pub codex_linux_sandbox_exe: Option, + pub main_execve_wrapper_exe: Option, +} + /// Keeps the per-session PATH entry alive and locked for the process lifetime. pub struct Arg0PathEntryGuard { _temp_dir: TempDir, _lock_file: File, + paths: Arg0DispatchPaths, } impl Arg0PathEntryGuard { - fn new(temp_dir: TempDir, lock_file: File) -> Self { + fn new(temp_dir: TempDir, lock_file: File, paths: Arg0DispatchPaths) -> Self { Self { _temp_dir: temp_dir, _lock_file: lock_file, + paths, } } + + pub fn paths(&self) -> &Arg0DispatchPaths { + &self.paths + } } pub fn arg0_dispatch() -> Option { @@ -124,33 +136,43 @@ pub fn arg0_dispatch() -> Option { /// 3. Derive the path to the current executable (so children can re-invoke the /// sandbox) when running on Linux. /// 4. Execute the provided async `main_fn` inside that runtime, forwarding any -/// error. Note that `main_fn` receives `codex_linux_sandbox_exe: -/// Option`, as an argument, which is generally needed as part of -/// constructing [`codex_core::config::Config`]. +/// error. Note that `main_fn` receives [`Arg0DispatchPaths`], which +/// contains the helper executable paths needed to construct +/// [`codex_core::config::Config`]. /// /// This function should be used to wrap any `main()` function in binary crates /// in this workspace that depends on these helper CLIs. pub fn arg0_dispatch_or_else(main_fn: F) -> anyhow::Result<()> where - F: FnOnce(Option) -> Fut, + F: FnOnce(Arg0DispatchPaths) -> Fut, Fut: Future>, { // Retain the TempDir so it exists for the lifetime of the invocation of // this executable. Admittedly, we could invoke `keep()` on it, but it // would be nice to avoid leaving temporary directories behind, if possible. - let _path_entry = arg0_dispatch(); + let path_entry = arg0_dispatch(); // Regular invocation – create a Tokio runtime and execute the provided // async entry-point. let runtime = build_runtime()?; runtime.block_on(async move { - let codex_linux_sandbox_exe: Option = if cfg!(target_os = "linux") { - std::env::current_exe().ok() - } else { - None + let current_exe = std::env::current_exe().ok(); + let paths = Arg0DispatchPaths { + codex_linux_sandbox_exe: if cfg!(target_os = "linux") { + current_exe.or_else(|| { + path_entry + .as_ref() + .and_then(|path_entry| path_entry.paths().codex_linux_sandbox_exe.clone()) + }) + } else { + None + }, + main_execve_wrapper_exe: path_entry + .as_ref() + .and_then(|path_entry| path_entry.paths().main_execve_wrapper_exe.clone()), }; - main_fn(codex_linux_sandbox_exe).await + main_fn(paths).await }) } @@ -301,7 +323,17 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result std::io::Result<()> { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f8a8a3a6fb..42ded11c06 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -3,6 +3,7 @@ use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; use clap_complete::generate; +use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; @@ -543,13 +544,13 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str { } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { - cli_main(codex_linux_sandbox_exe).await?; + arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { + cli_main(arg0_paths).await?; Ok(()) }) } -async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { +async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let MultitoolCli { config_overrides: mut root_config_overrides, feature_toggles, @@ -567,7 +568,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() &mut interactive.config_overrides, root_config_overrides.clone(), ); - let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, arg0_paths.clone()).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { @@ -575,7 +576,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() &mut exec_cli.config_overrides, root_config_overrides.clone(), ); - codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; + codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::Review(review_args)) => { let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; @@ -584,10 +585,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() &mut exec_cli.config_overrides, root_config_overrides.clone(), ); - codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; + codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::McpServer) => { - codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?; + codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { // Propagate any root-level config overrides (e.g. `-c key=value`). @@ -598,7 +599,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() None => { let transport = app_server_cli.listen; codex_app_server::run_main_with_transport( - codex_linux_sandbox_exe, + arg0_paths.clone(), root_config_overrides, codex_core::config_loader::LoaderOverrides::default(), app_server_cli.analytics_default_enabled, @@ -642,7 +643,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() all, config_overrides, ); - let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, arg0_paths.clone()).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Fork(ForkCommand { @@ -659,7 +660,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() all, config_overrides, ); - let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, arg0_paths.clone()).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { @@ -708,7 +709,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() &mut cloud_cli.config_overrides, root_config_overrides.clone(), ); - codex_cloud_tasks::run_main(cloud_cli, codex_linux_sandbox_exe).await?; + codex_cloud_tasks::run_main(cloud_cli, arg0_paths.codex_linux_sandbox_exe.clone()) + .await?; } Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { SandboxCommand::Macos(mut seatbelt_cli) => { @@ -718,7 +720,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); codex_cli::debug_sandbox::run_command_under_seatbelt( seatbelt_cli, - codex_linux_sandbox_exe, + arg0_paths.codex_linux_sandbox_exe.clone(), ) .await?; } @@ -729,7 +731,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); codex_cli::debug_sandbox::run_command_under_landlock( landlock_cli, - codex_linux_sandbox_exe, + arg0_paths.codex_linux_sandbox_exe.clone(), ) .await?; } @@ -740,7 +742,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); codex_cli::debug_sandbox::run_command_under_windows( windows_cli, - codex_linux_sandbox_exe, + arg0_paths.codex_linux_sandbox_exe.clone(), ) .await?; } @@ -887,7 +889,7 @@ fn prepend_config_flags( async fn run_interactive_tui( mut interactive: TuiCli, - codex_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, ) -> std::io::Result { if let Some(prompt) = interactive.prompt.take() { // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. @@ -912,7 +914,7 @@ async fn run_interactive_tui( } } - codex_tui::run_main(interactive, codex_linux_sandbox_exe).await + codex_tui::run_main(interactive, arg0_paths).await } fn confirm(prompt: &str) -> std::io::Result { diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 5d132d58bc..820f35cb1a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -51,6 +51,7 @@ codex-utils-readiness = { workspace = true } codex-secrets = { workspace = true } codex-utils-string = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } +csv = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } encoding_rs = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e3d93cf966..ca7fb51579 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -29,12 +29,24 @@ "$ref": "#/definitions/AgentRoleToml" }, "properties": { + "job_max_runtime_seconds": { + "description": "Default maximum runtime in seconds for agent job workers.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, "max_depth": { "description": "Maximum nesting depth allowed for spawned agent threads. Root sessions start at depth 0.", "format": "int32", "minimum": 1.0, "type": "integer" }, + "max_spawn_depth": { + "description": "Maximum depth for thread-spawned subagents.", + "format": "uint", + "minimum": 1.0, + "type": "integer" + }, "max_threads": { "description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.", "format": "uint", @@ -367,6 +379,9 @@ "prevent_idle_sleep": { "type": "boolean" }, + "realtime_conversation": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, @@ -1657,6 +1672,9 @@ "prevent_idle_sleep": { "type": "boolean" }, + "realtime_conversation": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, @@ -2034,6 +2052,14 @@ ], "description": "User-level skill config entries keyed by SKILL.md path." }, + "sqlite_home": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Directory where Codex stores the SQLite state DB. Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses a temp dir under WorkspaceWrite sandboxing and `$CODEX_HOME` for other modes." + }, "suppress_unstable_features_warning": { "description": "Suppress warnings about unstable (under development) features.", "type": "boolean" diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index 422e10b651..b8db6e397d 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -1,3 +1,4 @@ +use crate::config::DEFAULT_AGENT_MAX_SPAWN_DEPTH; use crate::error::CodexErr; use crate::error::Result; use codex_protocol::ThreadId; @@ -30,7 +31,6 @@ struct ActiveAgents { used_agent_nicknames: HashSet, nickname_reset_count: usize, } - fn session_depth(session_source: &SessionSource) -> i32 { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, @@ -43,6 +43,10 @@ pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 { session_depth(session_source).saturating_add(1) } +pub(crate) fn max_thread_spawn_depth(max_depth: Option) -> i32 { + let max_depth = max_depth.or(DEFAULT_AGENT_MAX_SPAWN_DEPTH).unwrap_or(1); + i32::try_from(max_depth).unwrap_or(i32::MAX) +} pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bool { depth > max_depth } diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 15be909c3d..6ae7d96159 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -6,5 +6,6 @@ pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; pub(crate) use guards::exceeds_thread_spawn_depth_limit; +pub(crate) use guards::max_thread_spawn_depth; pub(crate) use guards::next_thread_spawn_depth; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dd43794597..81fad6db41 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -640,6 +640,7 @@ impl TurnContext { model_info: &model_info, features: &features, web_search_mode: self.tools_config.web_search_mode, + session_source: self.session_source.clone(), }) .with_allow_login_shell(self.tools_config.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); @@ -975,6 +976,7 @@ impl Session { model_info: &model_info, features: &per_turn_config.features, web_search_mode: Some(per_turn_config.web_search_mode.value()), + session_source: session_source.clone(), }) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) .with_agent_roles(per_turn_config.agent_roles.clone()); @@ -1329,6 +1331,7 @@ impl Session { config.background_terminal_max_timeout, ), shell_zsh_path: config.zsh_path.clone(), + main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( Arc::clone(&config), Arc::clone(&auth_manager), @@ -4592,6 +4595,7 @@ async fn spawn_review_thread( model_info: &review_model_info, features: &review_features, web_search_mode: Some(review_web_search_mode), + session_source: parent_turn_context.session_source.clone(), }) .with_allow_login_shell(config.permissions.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); @@ -4782,15 +4786,6 @@ pub(crate) async fn run_turn( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, event).await; - if turn_context.config.features.enabled(Feature::SkillApproval) { - let _ = sess - .request_skill_approval( - turn_context.as_ref(), - turn_context.sub_id.clone(), - "test-skill".to_string(), - ) - .await; - } // TODO(ccunningham): Pre-turn compaction runs before context updates and the // new user message are recorded. Estimate pending incoming items (context // diffs/full reinjection + user input) and trigger compaction preemptively @@ -8207,6 +8202,7 @@ mod tests { config.background_terminal_max_timeout, ), shell_zsh_path: None, + main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( Arc::clone(&config), Arc::clone(&auth_manager), @@ -8362,6 +8358,7 @@ mod tests { config.background_terminal_max_timeout, ), shell_zsh_path: None, + main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( Arc::clone(&config), Arc::clone(&auth_manager), @@ -9213,20 +9210,23 @@ mod tests { let timeout_ms = 1000; let sandbox_permissions = SandboxPermissions::RequireEscalated; + let command = if cfg!(windows) { + vec![ + "cmd.exe".to_string(), + "/C".to_string(), + "echo hi".to_string(), + ] + } else { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hi".to_string(), + ] + }; let params = ExecParams { - command: if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }, + command: command.clone(), + original_command: shlex::try_join(command.iter().map(String::as_str)) + .unwrap_or_else(|_| command.join(" ")), cwd: turn_context.cwd.clone(), expiration: timeout_ms.into(), env: HashMap::new(), @@ -9240,6 +9240,7 @@ mod tests { let params2 = ExecParams { sandbox_permissions: SandboxPermissions::UseDefault, command: params.command.clone(), + original_command: params.original_command.clone(), cwd: params.cwd.clone(), expiration: timeout_ms.into(), env: HashMap::new(), @@ -9272,6 +9273,7 @@ mod tests { }) .to_string(), }, + source: ToolCallSource::Direct, }) .await; @@ -9311,6 +9313,7 @@ mod tests { }) .to_string(), }, + source: ToolCallSource::Direct, }) .await; @@ -9370,6 +9373,7 @@ mod tests { }) .to_string(), }, + source: ToolCallSource::Direct, }) .await; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1f81eaca42..caa6a523f2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -115,8 +115,35 @@ pub use codex_git::GhostSnapshotConfig; /// the context window. pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(6); +pub(crate) const DEFAULT_AGENT_MAX_SPAWN_DEPTH: Option = Some(2); pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1; +pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option = None; +pub const CONFIG_TOML_FILE: &str = "config.toml"; + +fn default_sqlite_home(sandbox_policy: &SandboxPolicy, codex_home: &Path) -> PathBuf { + if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { + let mut path = std::env::temp_dir(); + path.push("codex-sqlite"); + path + } else { + codex_home.to_path_buf() + } +} + +fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { + let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let path = PathBuf::from(trimmed); + if path.is_absolute() { + Some(path) + } else { + Some(resolved_cwd.join(path)) + } +} #[cfg(test)] pub(crate) fn test_config() -> Config { let codex_home = tempdir().expect("create temp dir"); @@ -330,6 +357,10 @@ pub struct Config { /// Maximum number of agent threads that can be open concurrently. pub agent_max_threads: Option, + /// Maximum depth for thread-spawned subagents. + pub agent_max_spawn_depth: Option, + /// Maximum runtime in seconds for agent job workers before they are failed. + pub agent_job_max_runtime_seconds: Option, /// Maximum nesting depth allowed for spawned agent threads. pub agent_max_depth: i32, @@ -344,6 +375,9 @@ pub struct Config { /// overridden by the `CODEX_HOME` environment variable). pub codex_home: PathBuf, + /// Directory where Codex stores the SQLite state DB. + pub sqlite_home: PathBuf, + /// Directory where Codex writes log files (defaults to `$CODEX_HOME/log`). pub log_dir: PathBuf, @@ -365,6 +399,11 @@ pub struct Config { /// When this program is invoked, arg0 will be set to `codex-linux-sandbox`. pub codex_linux_sandbox_exe: Option, + /// Path to the `codex-execve-wrapper` executable used for shell + /// escalation. This cannot be set in the config file: it must be set in + /// code via [`ConfigOverrides`]. + pub main_execve_wrapper_exe: Option, + /// Optional absolute path to the Node runtime used by `js_repl`. pub js_repl_node_path: Option, @@ -612,7 +651,8 @@ impl Config { /// designed to use [AskForApproval::Never] exclusively. /// /// Further, [ConfigOverrides] contains some options that are not supported - /// in [ConfigToml], such as `cwd` and `codex_linux_sandbox_exe`. + /// in [ConfigToml], such as `cwd`, `codex_linux_sandbox_exe`, and + /// `main_execve_wrapper_exe`. pub async fn load_with_cli_overrides_and_harness_overrides( cli_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, @@ -1108,6 +1148,11 @@ pub struct ConfigToml { #[serde(default)] pub history: Option, + /// Directory where Codex stores the SQLite state DB. + /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses a temp dir + /// under WorkspaceWrite sandboxing and `$CODEX_HOME` for other modes. + pub sqlite_home: Option, + /// Directory where Codex writes log files, for example `codex-tui.log`. /// Defaults to `$CODEX_HOME/log`. pub log_dir: Option, @@ -1295,11 +1340,16 @@ pub struct AgentsToml { /// When unset, no limit is enforced. #[schemars(range(min = 1))] pub max_threads: Option, - + /// Maximum depth for thread-spawned subagents. + #[schemars(range(min = 1))] + pub max_spawn_depth: Option, /// Maximum nesting depth allowed for spawned agent threads. /// Root sessions start at depth 0. #[schemars(range(min = 1))] pub max_depth: Option, + /// Default maximum runtime in seconds for agent job workers. + #[schemars(range(min = 1))] + pub job_max_runtime_seconds: Option, /// User-defined role declarations keyed by role name. /// @@ -1492,6 +1542,7 @@ pub struct ConfigOverrides { pub model_provider: Option, pub config_profile: Option, pub codex_linux_sandbox_exe: Option, + pub main_execve_wrapper_exe: Option, pub js_repl_node_path: Option, pub js_repl_node_module_dirs: Option>, pub zsh_path: Option, @@ -1621,6 +1672,7 @@ impl Config { model_provider, config_profile: config_profile_key, codex_linux_sandbox_exe, + main_execve_wrapper_exe, js_repl_node_path: js_repl_node_path_override, js_repl_node_module_dirs: js_repl_node_module_dirs_override, zsh_path: zsh_path_override, @@ -1813,6 +1865,44 @@ impl Config { }) .transpose()? .unwrap_or_default(); + let agent_max_spawn_depth = cfg + .agents + .as_ref() + .and_then(|agents| agents.max_spawn_depth) + .or(DEFAULT_AGENT_MAX_SPAWN_DEPTH); + if agent_max_spawn_depth == Some(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_spawn_depth must be at least 1", + )); + } + if let Some(max_spawn_depth) = agent_max_spawn_depth + && max_spawn_depth > i32::MAX as usize + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_spawn_depth must fit within a 32-bit signed integer", + )); + } + let agent_job_max_runtime_seconds = cfg + .agents + .as_ref() + .and_then(|agents| agents.job_max_runtime_seconds) + .or(DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS); + if agent_job_max_runtime_seconds == Some(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.job_max_runtime_seconds must be at least 1", + )); + } + if let Some(max_runtime_seconds) = agent_job_max_runtime_seconds + && max_runtime_seconds > i64::MAX as u64 + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.job_max_runtime_seconds must fit within a 64-bit signed integer", + )); + } let background_terminal_max_timeout = cfg .background_terminal_max_timeout .unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS) @@ -1937,6 +2027,12 @@ impl Config { p.push("log"); p }); + let sqlite_home = cfg + .sqlite_home + .as_ref() + .map(AbsolutePathBuf::to_path_buf) + .or_else(|| resolve_sqlite_home_env(&resolved_cwd)) + .unwrap_or_else(|| default_sqlite_home(&sandbox_policy, &codex_home)); // Ensure that every field of ConfigRequirements is applied to the final // Config. @@ -2053,13 +2149,17 @@ impl Config { agent_max_depth, agent_roles, memories: cfg.memories.unwrap_or_default().into(), + agent_max_spawn_depth, + agent_job_max_runtime_seconds, codex_home, + sqlite_home, log_dir, config_layer_stack, history, ephemeral: ephemeral.unwrap_or_default(), file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_linux_sandbox_exe, + main_execve_wrapper_exe, js_repl_node_path, js_repl_node_module_dirs, zsh_path, @@ -4387,7 +4487,9 @@ model = "gpt-5.1-codex" let cfg = ConfigToml { agents: Some(AgentsToml { max_threads: None, + max_spawn_depth: None, max_depth: None, + job_max_runtime_seconds: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4661,7 +4763,10 @@ model_verbosity = "high" agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), + agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH, + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, codex_home: fixture.codex_home(), + sqlite_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), config_layer_stack: Default::default(), startup_warnings: Vec::new(), @@ -4669,6 +4774,7 @@ model_verbosity = "high" ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, js_repl_node_path: None, js_repl_node_module_dirs: Vec::new(), zsh_path: None, @@ -4784,7 +4890,10 @@ model_verbosity = "high" agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), + agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH, + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, codex_home: fixture.codex_home(), + sqlite_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), config_layer_stack: Default::default(), startup_warnings: Vec::new(), @@ -4792,6 +4901,7 @@ model_verbosity = "high" ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, js_repl_node_path: None, js_repl_node_module_dirs: Vec::new(), zsh_path: None, @@ -4905,7 +5015,10 @@ model_verbosity = "high" agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), + agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH, + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, codex_home: fixture.codex_home(), + sqlite_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), config_layer_stack: Default::default(), startup_warnings: Vec::new(), @@ -4913,6 +5026,7 @@ model_verbosity = "high" ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, js_repl_node_path: None, js_repl_node_module_dirs: Vec::new(), zsh_path: None, @@ -5012,7 +5126,10 @@ model_verbosity = "high" agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), + agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH, + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, codex_home: fixture.codex_home(), + sqlite_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), config_layer_stack: Default::default(), startup_warnings: Vec::new(), @@ -5020,6 +5137,7 @@ model_verbosity = "high" ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, js_repl_node_path: None, js_repl_node_module_dirs: Vec::new(), zsh_path: None, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 288ee7f212..1d1169303c 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -62,6 +62,7 @@ pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; #[derive(Debug)] pub struct ExecParams { pub command: Vec, + pub original_command: String, pub cwd: PathBuf, pub expiration: ExecExpiration, pub env: HashMap, @@ -180,6 +181,7 @@ pub async fn process_exec_tool_call( let ExecParams { command, + original_command: _, cwd, mut env, expiration, @@ -249,6 +251,8 @@ pub(crate) async fn execute_exec_env( } = env; let params = ExecParams { + original_command: shlex::try_join(command.iter().map(String::as_str)) + .unwrap_or_else(|_| command.join(" ")), command, cwd, expiration, @@ -1121,6 +1125,8 @@ mod tests { ]; let env: HashMap = std::env::vars().collect(); let params = ExecParams { + original_command: shlex::try_join(command.iter().map(String::as_str)) + .unwrap_or_else(|_| command.join(" ")), command, cwd: std::env::current_dir()?, expiration: 500.into(), @@ -1174,6 +1180,8 @@ mod tests { let cancel_token = CancellationToken::new(); let cancel_tx = cancel_token.clone(); let params = ExecParams { + original_command: shlex::try_join(command.iter().map(String::as_str)) + .unwrap_or_else(|_| command.join(" ")), command, cwd: cwd.clone(), expiration: ExecExpiration::Cancellation(cancel_token), diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index f5d87f7a93..d3bace8519 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -144,6 +144,8 @@ pub enum Feature { Personality, /// Enable voice transcription in the TUI composer. VoiceTranscription, + /// Enable experimental realtime voice conversation mode in the TUI. + RealtimeConversation, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, /// Use the Responses API WebSocket transport for OpenAI by default. @@ -651,10 +653,20 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RealtimeConversation, + key: "realtime_conversation", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::PreventIdleSleep, key: "prevent_idle_sleep", - stage: if cfg!(target_os = "macos") { + stage: if cfg!(any( + target_os = "macos", + target_os = "linux", + target_os = "windows" + )) { Stage::Experimental { name: "Prevent sleep while running", menu_description: "Keep your computer awake while Codex is running a thread.", diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index e43e318a15..4b1da3e58a 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -159,12 +159,33 @@ impl ModelsManager { best } + /// Retry metadata lookup for a single namespaced slug like `namespace/model-name`. + /// + /// This only strips one leading namespace segment and only when the namespace is ASCII + /// alphanumeric/underscore (`\\w+`) to avoid broadly matching arbitrary aliases. + fn find_model_by_namespaced_suffix(model: &str, candidates: &[ModelInfo]) -> Option { + let (namespace, suffix) = model.split_once('/')?; + if suffix.contains('/') { + return None; + } + if !namespace + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return None; + } + Self::find_model_by_longest_prefix(suffix, candidates) + } + fn construct_model_info_from_candidates( model: &str, candidates: &[ModelInfo], config: &Config, ) -> ModelInfo { - let remote = Self::find_model_by_longest_prefix(model, candidates); + // First use the normal longest-prefix match. If that misses, allow a narrowly scoped + // retry for namespaced slugs like `custom/gpt-5.3-codex`. + let remote = Self::find_model_by_longest_prefix(model, candidates) + .or_else(|| Self::find_model_by_namespaced_suffix(model, candidates)); let model_info = if let Some(remote) = remote { ModelInfo { slug: model.to_string(), @@ -520,6 +541,58 @@ mod tests { assert!(!model_info.used_fallback_model_metadata); } + #[tokio::test] + async fn get_model_info_matches_namespaced_suffix() { + let codex_home = tempdir().expect("temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new(codex_home.path().to_path_buf(), auth_manager, None); + let known_slug = manager + .get_remote_models() + .await + .first() + .expect("bundled models should include at least one model") + .slug + .clone(); + let namespaced_model = format!("custom/{known_slug}"); + + let model_info = manager.get_model_info(&namespaced_model, &config).await; + + assert_eq!(model_info.slug, namespaced_model); + assert!(!model_info.used_fallback_model_metadata); + } + + #[tokio::test] + async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { + let codex_home = tempdir().expect("temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new(codex_home.path().to_path_buf(), auth_manager, None); + let known_slug = manager + .get_remote_models() + .await + .first() + .expect("bundled models should include at least one model") + .slug + .clone(); + let namespaced_model = format!("ns1/ns2/{known_slug}"); + + let model_info = manager.get_model_info(&namespaced_model, &config).await; + + assert_eq!(model_info.slug, namespaced_model); + assert!(model_info.used_fallback_model_metadata); + } + #[tokio::test] async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 0147a766fe..8323b86a1b 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -3,23 +3,31 @@ //! Project-level documentation is primarily stored in files named `AGENTS.md`. //! Additional fallback filenames can be configured via `project_doc_fallback_filenames`. //! We include the concatenation of all files found along the path from the -//! repository root to the current working directory as follows: +//! project root to the current working directory as follows: //! -//! 1. Determine the Git repository root by walking upwards from the current -//! working directory until a `.git` directory or file is found. If no Git -//! root is found, only the current working directory is considered. -//! 2. Collect every `AGENTS.md` found from the repository root down to the +//! 1. Determine the project root by walking upwards from the current working +//! directory until a configured `project_root_markers` entry is found. +//! When `project_root_markers` is unset, the default marker list is used +//! (`.git`). If no marker is found, only the current working directory is +//! considered. An empty marker list disables parent traversal. +//! 2. Collect every `AGENTS.md` found from the project root down to the //! current working directory (inclusive) and concatenate their contents in //! that order. -//! 3. We do **not** walk past the Git root. +//! 3. We do **not** walk past the project root. use crate::config::Config; +use crate::config_loader::ConfigLayerStackOrdering; +use crate::config_loader::default_project_root_markers; +use crate::config_loader::merge_toml_values; +use crate::config_loader::project_root_markers_from_config; use crate::features::Feature; use crate::skills::SkillMetadata; use crate::skills::render_skills_section; +use codex_app_server_protocol::ConfigLayerSource; use dunce::canonicalize as normalize_path; use std::path::PathBuf; use tokio::io::AsyncReadExt; +use toml::Value as TomlValue; use tracing::error; pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str = @@ -178,7 +186,7 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result std::io::Result> { @@ -187,43 +195,62 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result = vec![dir.clone()]; - let mut git_root: Option = None; - let mut cursor = dir; - while let Some(parent) = cursor.parent() { - let git_marker = cursor.join(".git"); - let git_exists = match std::fs::metadata(&git_marker) { - Ok(_) => true, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(e) => return Err(e), - }; - - if git_exists { - git_root = Some(cursor.clone()); - break; + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) + { + if matches!(layer.name, ConfigLayerSource::Project { .. }) { + continue; } - - chain.push(parent.to_path_buf()); - cursor = parent.to_path_buf(); + merge_toml_values(&mut merged, &layer.config); } - - let search_dirs: Vec = if let Some(root) = git_root { - let mut dirs: Vec = Vec::new(); - let mut saw_root = false; - for p in chain.iter().rev() { - if !saw_root { - if p == &root { - saw_root = true; - } else { - continue; + let project_root_markers = match project_root_markers_from_config(&merged) { + Ok(Some(markers)) => markers, + Ok(None) => default_project_root_markers(), + Err(err) => { + tracing::warn!("invalid project_root_markers: {err}"); + default_project_root_markers() + } + }; + let mut project_root = None; + if !project_root_markers.is_empty() { + for ancestor in dir.ancestors() { + for marker in &project_root_markers { + let marker_path = ancestor.join(marker); + let marker_exists = match std::fs::metadata(&marker_path) { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(e), + }; + if marker_exists { + project_root = Some(ancestor.to_path_buf()); + break; } } - dirs.push(p.clone()); + if project_root.is_some() { + break; + } } + } + + let search_dirs: Vec = if let Some(root) = project_root { + let mut dirs = Vec::new(); + let mut cursor = dir.as_path(); + loop { + dirs.push(cursor.to_path_buf()); + if cursor == root { + break; + } + let Some(parent) = cursor.parent() else { + break; + }; + cursor = parent; + } + dirs.reverse(); dirs } else { - vec![config.cwd.clone()] + vec![dir] }; let mut found: Vec = Vec::new(); @@ -312,6 +339,35 @@ mod tests { config } + async fn make_config_with_project_root_markers( + root: &TempDir, + limit: usize, + instructions: Option<&str>, + markers: &[&str], + ) -> Config { + let codex_home = TempDir::new().unwrap(); + let cli_overrides = vec![( + "project_root_markers".to_string(), + TomlValue::Array( + markers + .iter() + .map(|marker| TomlValue::String((*marker).to_string())) + .collect(), + ), + )]; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(cli_overrides) + .build() + .await + .expect("defaults for test should always succeed"); + + config.cwd = root.path().to_path_buf(); + config.project_doc_max_bytes = limit; + config.user_instructions = instructions.map(ToOwned::to_owned); + config + } + fn load_test_skills(config: &Config) -> crate::skills::SkillLoadOutcome { load_skills_from_roots([SkillRoot { path: config.codex_home.join("skills"), @@ -498,6 +554,35 @@ mod tests { assert_eq!(res, "root doc\n\ncrate doc"); } + #[tokio::test] + async fn project_root_markers_are_honored_for_agents_discovery() { + let root = tempfile::tempdir().expect("tempdir"); + fs::write(root.path().join(".codex-root"), "").unwrap(); + fs::write(root.path().join("AGENTS.md"), "parent doc").unwrap(); + + let nested = root.path().join("dir1"); + fs::create_dir_all(nested.join(".git")).unwrap(); + fs::write(nested.join("AGENTS.md"), "child doc").unwrap(); + + let mut cfg = + make_config_with_project_root_markers(&root, 4096, None, &[".codex-root"]).await; + cfg.cwd = nested; + + let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); + let expected_parent = + dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"); + let expected_child = + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"); + assert_eq!(discovery.len(), 2); + assert_eq!(discovery[0], expected_parent); + assert_eq!(discovery[1], expected_child); + + let res = get_user_instructions(&cfg, None) + .await + .expect("doc expected"); + assert_eq!(res, "parent doc\n\nchild doc"); + } + /// AGENTS.override.md is preferred over AGENTS.md when both are present. #[tokio::test] async fn agents_local_md_preferred() { diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 4e5ddd2e40..7dc122e931 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -123,6 +123,13 @@ impl ShellSnapshot { let path = codex_home .join(SNAPSHOT_DIR) .join(format!("{session_id}.{extension}")); + let nonce = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let temp_path = codex_home + .join(SNAPSHOT_DIR) + .join(format!("{session_id}.tmp-{nonce}")); // Clean the (unlikely) leaked snapshot files. let codex_home = codex_home.to_path_buf(); @@ -134,31 +141,42 @@ impl ShellSnapshot { }); // Make the new snapshot. - let path = match write_shell_snapshot(shell.shell_type.clone(), &path, session_cwd).await { - Ok(path) => { - tracing::info!("Shell snapshot successfully created: {}", path.display()); - path - } - Err(err) => { - tracing::warn!( - "Failed to create shell snapshot for {}: {err:?}", - shell.name() - ); - return Err("write_failed"); - } - }; + let temp_path = + match write_shell_snapshot(shell.shell_type.clone(), &temp_path, session_cwd).await { + Ok(path) => { + tracing::info!("Shell snapshot successfully created: {}", path.display()); + path + } + Err(err) => { + tracing::warn!( + "Failed to create shell snapshot for {}: {err:?}", + shell.name() + ); + return Err("write_failed"); + } + }; - let snapshot = Self { - path, + let temp_snapshot = Self { + path: temp_path.clone(), cwd: session_cwd.to_path_buf(), }; - if let Err(err) = validate_snapshot(shell, &snapshot.path, session_cwd).await { + if let Err(err) = validate_snapshot(shell, &temp_snapshot.path, session_cwd).await { tracing::error!("Shell snapshot validation failed: {err:?}"); + remove_snapshot_file(&temp_snapshot.path).await; return Err("validation_failed"); } - Ok(snapshot) + if let Err(err) = fs::rename(&temp_snapshot.path, &path).await { + tracing::warn!("Failed to finalize shell snapshot: {err:?}"); + remove_snapshot_file(&temp_snapshot.path).await; + return Err("write_failed"); + } + + Ok(Self { + path, + cwd: session_cwd.to_path_buf(), + }) } } diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core/src/skills/invocation_utils.rs index 8c67dc633e..fd847091de 100644 --- a/codex-rs/core/src/skills/invocation_utils.rs +++ b/codex-rs/core/src/skills/invocation_utils.rs @@ -7,8 +7,21 @@ use crate::analytics_client::SkillInvocation; use crate::analytics_client::build_track_events_context; use crate::codex::Session; use crate::codex::TurnContext; +use crate::features::Feature; use crate::skills::SkillLoadOutcome; use crate::skills::SkillMetadata; +use codex_protocol::protocol::ReviewDecision; +use serde::Serialize; + +pub(crate) const SKILL_APPROVAL_DECLINED_MESSAGE: &str = + "This script is part of the skill and the user declined the skill usage"; + +#[derive(Debug, Serialize)] +struct SkillApprovalCacheKey { + skill_name: String, + skill_path: PathBuf, + skill_scope: codex_protocol::protocol::SkillScope, +} pub(crate) fn build_implicit_skill_path_indexes( skills: Vec, @@ -41,8 +54,11 @@ fn detect_implicit_skill_invocation_for_command( let workdir = normalize_path(workdir.as_path()); let tokens = tokenize_command(command); - if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), workdir.as_path()) - { + if let Some(candidate) = detect_implicit_skill_script_invocation_for_tokens( + outcome, + tokens.as_slice(), + workdir.as_path(), + ) { return Some(candidate); } @@ -53,6 +69,82 @@ fn detect_implicit_skill_invocation_for_command( None } +pub(crate) fn detect_implicit_skill_script_invocation_for_command( + outcome: &SkillLoadOutcome, + command: &str, + workdir: &Path, +) -> Option { + let tokens = tokenize_command(command); + + detect_implicit_skill_script_invocation_for_tokens(outcome, tokens.as_slice(), workdir) +} + +pub(crate) fn detect_implicit_skill_script_invocation_for_tokens( + outcome: &SkillLoadOutcome, + command: &[String], + workdir: &Path, +) -> Option { + detect_skill_script_run(outcome, command, workdir) +} + +fn tokenize_command(command: &str) -> Vec { + shlex::split(command).unwrap_or_else(|| { + command + .split_whitespace() + .map(std::string::ToString::to_string) + .collect() + }) +} + +pub(crate) async fn ensure_skill_approval_for_command( + sess: &Session, + turn_context: &TurnContext, + item_id: &str, + command: &str, + workdir: &Path, +) -> bool { + if !turn_context.features.enabled(Feature::SkillApproval) { + return true; + } + + let workdir = normalize_path(workdir); + let Some(skill) = detect_implicit_skill_script_invocation_for_command( + turn_context.turn_skills.outcome.as_ref(), + command, + workdir.as_path(), + ) else { + return true; + }; + + let cache_key = SkillApprovalCacheKey { + skill_name: skill.name.clone(), + skill_path: skill.path.clone(), + skill_scope: skill.scope, + }; + let already_approved = { + let store = sess.services.tool_approvals.lock().await; + matches!( + store.get(&cache_key), + Some(ReviewDecision::ApprovedForSession) + ) + }; + if already_approved { + return true; + } + + let approved = sess + .request_skill_approval(turn_context, item_id.to_string(), skill.name) + .await + .is_some_and(|response| response.approved); + if !approved { + return false; + } + + let mut store = sess.services.tool_approvals.lock().await; + store.put(cache_key, ReviewDecision::ApprovedForSession); + true +} + pub(crate) async fn maybe_emit_implicit_skill_invocation( sess: &Session, turn_context: &TurnContext, @@ -115,15 +207,6 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation( ); } -fn tokenize_command(command: &str) -> Vec { - shlex::split(command).unwrap_or_else(|| { - command - .split_whitespace() - .map(std::string::ToString::to_string) - .collect() - }) -} - fn script_run_token(tokens: &[String]) -> Option<&str> { const RUNNERS: [&str; 10] = [ "python", "python3", "bash", "zsh", "sh", "node", "deno", "ruby", "perl", "pwsh", @@ -234,6 +317,7 @@ fn normalize_path(path: &Path) -> PathBuf { mod tests { use super::SkillLoadOutcome; use super::SkillMetadata; + use super::detect_implicit_skill_script_invocation_for_command; use super::detect_skill_doc_read; use super::detect_skill_script_run; use super::normalize_path; @@ -353,4 +437,50 @@ mod tests { Some("test-skill".to_string()) ); } + + #[test] + fn implicit_skill_script_invocation_matches_command() { + let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); + let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts")); + let skill = test_skill_metadata(skill_doc_path); + let outcome = SkillLoadOutcome { + implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])), + implicit_skills_by_doc_path: Arc::new(HashMap::new()), + ..Default::default() + }; + + let found = detect_implicit_skill_script_invocation_for_command( + &outcome, + "python scripts/fetch_comments.py", + Path::new("/tmp/skill-test"), + ); + + assert_eq!( + found.map(|value| value.name), + Some("test-skill".to_string()) + ); + } + + #[test] + fn implicit_skill_script_invocation_ignores_doc_reads() { + let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); + let normalized_skill_doc_path = normalize_path(skill_doc_path.as_path()); + let skill = test_skill_metadata(skill_doc_path); + let outcome = SkillLoadOutcome { + implicit_skills_by_scripts_dir: Arc::new(HashMap::new()), + implicit_skills_by_doc_path: Arc::new(HashMap::from([( + normalized_skill_doc_path, + skill, + )])), + ..Default::default() + }; + + let found = detect_implicit_skill_script_invocation_for_command( + &outcome, + "cat SKILL.md", + Path::new("/tmp/skill-test"), + ); + + assert_eq!(found, None); + } } diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 868a1d7def..6fccd71ccf 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -14,7 +14,9 @@ pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn; pub(crate) use injection::SkillInjections; pub(crate) use injection::build_skill_injections; pub(crate) use injection::collect_explicit_skill_mentions; +pub(crate) use invocation_utils::SKILL_APPROVAL_DECLINED_MESSAGE; pub(crate) use invocation_utils::build_implicit_skill_path_indexes; +pub(crate) use invocation_utils::ensure_skill_approval_for_command; pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation; pub use loader::load_skills; pub use manager::SkillsManager; diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 14ac226c58..2a541d38a9 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -29,6 +29,8 @@ pub(crate) struct SessionServices { pub(crate) unified_exec_manager: UnifiedExecProcessManager, #[cfg_attr(not(unix), allow(dead_code))] pub(crate) shell_zsh_path: Option, + #[cfg_attr(not(unix), allow(dead_code))] + pub(crate) main_execve_wrapper_exe: Option, pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) hooks: Hooks, pub(crate) rollout: Mutex>, diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index 0c5a14a51a..7f5b62a127 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -37,7 +37,7 @@ pub(crate) async fn init_if_enabled( return None; } let runtime = match codex_state::StateRuntime::init( - config.codex_home.clone(), + config.sqlite_home.clone(), config.model_provider_id.clone(), otel.cloned(), ) @@ -47,7 +47,7 @@ pub(crate) async fn init_if_enabled( Err(err) => { warn!( "failed to initialize state runtime at {}: {err}", - config.codex_home.display() + config.sqlite_home.display() ); if let Some(otel) = otel { otel.counter("codex.db.init", 1, &[("status", "init_error")]); @@ -79,20 +79,20 @@ pub(crate) async fn init_if_enabled( /// Get the DB if the feature is enabled and the DB exists. pub async fn get_state_db(config: &Config, otel: Option<&OtelManager>) -> Option { - let state_path = codex_state::state_db_path(config.codex_home.as_path()); + let state_path = codex_state::state_db_path(config.sqlite_home.as_path()); if !config.features.enabled(Feature::Sqlite) || !tokio::fs::try_exists(&state_path).await.unwrap_or(false) { return None; } let runtime = codex_state::StateRuntime::init( - config.codex_home.clone(), + config.sqlite_home.clone(), config.model_provider_id.clone(), otel.cloned(), ) .await .ok()?; - require_backfill_complete(runtime, config.codex_home.as_path()).await + require_backfill_complete(runtime, config.sqlite_home.as_path()).await } /// Open the state runtime when the SQLite file exists, without feature gating. diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index e9edd7db46..081d066873 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -16,6 +16,12 @@ use tokio::sync::Mutex; pub type SharedTurnDiffTracker = Arc>; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ToolCallSource { + Direct, + JsRepl, +} + #[derive(Clone)] pub struct ToolInvocation { pub session: Arc, @@ -24,6 +30,7 @@ pub struct ToolInvocation { pub call_id: String, pub tool_name: String, pub payload: ToolPayload, + pub source: ToolCallSource, } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs new file mode 100644 index 0000000000..6b36be05dd --- /dev/null +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -0,0 +1,1227 @@ +use crate::agent::exceeds_thread_spawn_depth_limit; +use crate::agent::max_thread_spawn_depth; +use crate::agent::next_thread_spawn_depth; +use crate::agent::status::is_final; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::config::Config; +use crate::error::CodexErr; +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::multi_agents::build_agent_spawn_config; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use async_trait::async_trait; +use codex_protocol::ThreadId; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::user_input::UserInput; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::time::Duration; +use tokio::time::Instant; +use uuid::Uuid; + +pub struct BatchJobHandler; + +const DEFAULT_AGENT_JOB_CONCURRENCY: usize = 16; +const MAX_AGENT_JOB_CONCURRENCY: usize = 64; +const STATUS_POLL_INTERVAL: Duration = Duration::from_millis(250); +const PROGRESS_EMIT_INTERVAL: Duration = Duration::from_secs(1); +const DEFAULT_AGENT_JOB_ITEM_TIMEOUT: Duration = Duration::from_secs(60 * 30); + +#[derive(Debug, Deserialize)] +struct SpawnAgentsOnCsvArgs { + csv_path: String, + instruction: String, + id_column: Option, + output_csv_path: Option, + output_schema: Option, + max_concurrency: Option, + max_workers: Option, + max_runtime_seconds: Option, +} + +#[derive(Debug, Deserialize)] +struct ReportAgentJobResultArgs { + job_id: String, + item_id: String, + result: Value, + stop: Option, +} + +#[derive(Debug, Serialize)] +struct SpawnAgentsOnCsvResult { + job_id: String, + status: String, + output_csv_path: String, + total_items: usize, + completed_items: usize, + failed_items: usize, + job_error: Option, + failed_item_errors: Option>, +} + +#[derive(Debug, Serialize)] +struct AgentJobFailureSummary { + item_id: String, + source_id: Option, + last_error: String, +} + +#[derive(Debug, Serialize)] +struct AgentJobProgressUpdate { + job_id: String, + total_items: usize, + pending_items: usize, + running_items: usize, + completed_items: usize, + failed_items: usize, + eta_seconds: Option, +} + +#[derive(Debug, Serialize)] +struct ReportAgentJobResultToolResult { + accepted: bool, +} + +#[derive(Debug, Clone)] +struct JobRunnerOptions { + max_concurrency: usize, + spawn_config: Config, +} + +#[derive(Debug, Clone)] +struct ActiveJobItem { + item_id: String, + started_at: Instant, +} + +struct JobProgressEmitter { + started_at: Instant, + last_emit_at: Instant, + last_processed: usize, + last_failed: usize, +} + +impl JobProgressEmitter { + fn new() -> Self { + let now = Instant::now(); + let last_emit_at = now.checked_sub(PROGRESS_EMIT_INTERVAL).unwrap_or(now); + Self { + started_at: now, + last_emit_at, + last_processed: 0, + last_failed: 0, + } + } + + async fn maybe_emit( + &mut self, + session: &Session, + turn: &TurnContext, + job_id: &str, + progress: &codex_state::AgentJobProgress, + force: bool, + ) -> anyhow::Result<()> { + let processed = progress.completed_items + progress.failed_items; + let should_emit = force + || processed != self.last_processed + || progress.failed_items != self.last_failed + || self.last_emit_at.elapsed() >= PROGRESS_EMIT_INTERVAL; + if !should_emit { + return Ok(()); + } + let elapsed = self.started_at.elapsed().as_secs_f64(); + let eta_seconds = if processed > 0 && elapsed > 0.0 { + let remaining = progress.total_items.saturating_sub(processed) as f64; + let rate = processed as f64 / elapsed; + if rate > 0.0 { + Some((remaining / rate).round() as u64) + } else { + None + } + } else { + None + }; + let update = AgentJobProgressUpdate { + job_id: job_id.to_string(), + total_items: progress.total_items, + pending_items: progress.pending_items, + running_items: progress.running_items, + completed_items: progress.completed_items, + failed_items: progress.failed_items, + eta_seconds, + }; + let payload = serde_json::to_string(&update)?; + session + .notify_background_event(turn, format!("agent_job_progress:{payload}")) + .await; + self.last_emit_at = Instant::now(); + self.last_processed = processed; + self.last_failed = progress.failed_items; + Ok(()) + } +} + +#[async_trait] +impl ToolHandler for BatchJobHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tool_name, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "agent jobs handler received unsupported payload".to_string(), + )); + } + }; + + match tool_name.as_str() { + "spawn_agents_on_csv" => spawn_agents_on_csv::handle(session, turn, arguments).await, + "report_agent_job_result" => report_agent_job_result::handle(session, arguments).await, + other => Err(FunctionCallError::RespondToModel(format!( + "unsupported agent job tool {other}" + ))), + } + } +} + +mod spawn_agents_on_csv { + use super::*; + + /// Create a new agent job from a CSV and run it to completion. + /// + /// Each CSV row becomes a job item. The instruction string is a template where `{column}` + /// placeholders are filled with values from that row. Results are reported by workers via + /// `report_agent_job_result`, then exported to CSV on completion. + pub async fn handle( + session: Arc, + turn: Arc, + arguments: String, + ) -> Result { + let args: SpawnAgentsOnCsvArgs = parse_arguments(arguments.as_str())?; + if args.instruction.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "instruction must be non-empty".to_string(), + )); + } + + let db = required_state_db(&session)?; + let input_path = turn.resolve_path(Some(args.csv_path)); + let input_path_display = input_path.display().to_string(); + let csv_content = tokio::fs::read_to_string(&input_path) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to read csv input {input_path_display}: {err}" + )) + })?; + let (headers, rows) = parse_csv(csv_content.as_str()).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse csv input: {err}")) + })?; + if headers.is_empty() { + return Err(FunctionCallError::RespondToModel( + "csv input must include a header row".to_string(), + )); + } + ensure_unique_headers(headers.as_slice())?; + + let id_column_index = args.id_column.as_ref().map_or(Ok(None), |column_name| { + headers + .iter() + .position(|header| header == column_name) + .map(Some) + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "id_column {column_name} was not found in csv headers" + )) + }) + })?; + + let mut items = Vec::with_capacity(rows.len()); + let mut seen_ids = HashSet::new(); + for (idx, row) in rows.into_iter().enumerate() { + if row.len() != headers.len() { + let row_index = idx + 2; + let row_len = row.len(); + let header_len = headers.len(); + return Err(FunctionCallError::RespondToModel(format!( + "csv row {row_index} has {row_len} fields but header has {header_len}" + ))); + } + + let source_id = id_column_index + .and_then(|index| row.get(index).cloned()) + .filter(|value| !value.trim().is_empty()); + let row_index = idx + 1; + let base_item_id = source_id + .clone() + .unwrap_or_else(|| format!("row-{row_index}")); + let mut item_id = base_item_id.clone(); + let mut suffix = 2usize; + while !seen_ids.insert(item_id.clone()) { + item_id = format!("{base_item_id}-{suffix}"); + suffix = suffix.saturating_add(1); + } + + let row_object = headers + .iter() + .zip(row.iter()) + .map(|(header, value)| (header.clone(), Value::String(value.clone()))) + .collect::>(); + items.push(codex_state::AgentJobItemCreateParams { + item_id, + row_index: idx as i64, + source_id, + row_json: Value::Object(row_object), + }); + } + + let job_id = Uuid::new_v4().to_string(); + let output_csv_path = args.output_csv_path.map_or_else( + || default_output_csv_path(input_path.as_path(), job_id.as_str()), + |path| turn.resolve_path(Some(path)), + ); + let job_suffix = &job_id[..8]; + let job_name = format!("agent-job-{job_suffix}"); + let max_runtime_seconds = normalize_max_runtime_seconds( + args.max_runtime_seconds + .or(turn.config.agent_job_max_runtime_seconds), + )?; + let _job = db + .create_agent_job( + &codex_state::AgentJobCreateParams { + id: job_id.clone(), + name: job_name, + instruction: args.instruction, + auto_export: true, + max_runtime_seconds, + output_schema_json: args.output_schema, + input_headers: headers, + input_csv_path: input_path.display().to_string(), + output_csv_path: output_csv_path.display().to_string(), + }, + items.as_slice(), + ) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to create agent job: {err}")) + })?; + + let requested_concurrency = args.max_concurrency.or(args.max_workers); + let options = match build_runner_options(&session, &turn, requested_concurrency).await { + Ok(options) => options, + Err(err) => { + let error_message = err.to_string(); + let _ = db + .mark_agent_job_failed(job_id.as_str(), error_message.as_str()) + .await; + return Err(err); + } + }; + db.mark_agent_job_running(job_id.as_str()) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to transition agent job {job_id} to running: {err}" + )) + })?; + let max_threads = turn.config.agent_max_threads; + let effective_concurrency = options.max_concurrency; + let message = format!( + "agent job concurrency: job_id={job_id} requested={requested_concurrency:?} max_threads={max_threads:?} effective={effective_concurrency}" + ); + let _ = session.notify_background_event(&turn, message).await; + if let Err(err) = run_agent_job_loop( + session.clone(), + turn.clone(), + db.clone(), + job_id.clone(), + options, + ) + .await + { + let error_message = format!("job runner failed: {err}"); + let _ = db + .mark_agent_job_failed(job_id.as_str(), error_message.as_str()) + .await; + return Err(FunctionCallError::RespondToModel(format!( + "agent job {job_id} failed: {err}" + ))); + } + + let job = db + .get_agent_job(job_id.as_str()) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to load agent job {job_id}: {err}" + )) + })? + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!("agent job {job_id} not found")) + })?; + let output_path = PathBuf::from(job.output_csv_path.clone()); + if !tokio::fs::try_exists(&output_path).await.unwrap_or(false) { + export_job_csv_snapshot(db.clone(), &job) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to export output csv {job_id}: {err}" + )) + })?; + } + let progress = db + .get_agent_job_progress(job_id.as_str()) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to load agent job progress {job_id}: {err}" + )) + })?; + let mut job_error = job.last_error.clone().filter(|err| !err.trim().is_empty()); + let failed_item_errors = if progress.failed_items > 0 { + let items = db + .list_agent_job_items( + job_id.as_str(), + Some(codex_state::AgentJobItemStatus::Failed), + Some(5), + ) + .await + .unwrap_or_default(); + let summaries: Vec<_> = items + .into_iter() + .filter_map(|item| { + let last_error = item.last_error.unwrap_or_default(); + if last_error.trim().is_empty() { + return None; + } + Some(AgentJobFailureSummary { + item_id: item.item_id, + source_id: item.source_id, + last_error, + }) + }) + .collect(); + if summaries.is_empty() { + if job_error.is_none() { + job_error = Some( + "agent job has failed items but no error details were recorded".to_string(), + ); + } + None + } else { + Some(summaries) + } + } else { + None + }; + let content = serde_json::to_string(&SpawnAgentsOnCsvResult { + job_id, + status: job.status.as_str().to_string(), + output_csv_path: job.output_csv_path, + total_items: progress.total_items, + completed_items: progress.completed_items, + failed_items: progress.failed_items, + job_error, + failed_item_errors, + }) + .map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize spawn_agents_on_csv result: {err}" + )) + })?; + Ok(ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + success: Some(true), + }) + } +} + +mod report_agent_job_result { + use super::*; + + pub async fn handle( + session: Arc, + arguments: String, + ) -> Result { + let args: ReportAgentJobResultArgs = parse_arguments(arguments.as_str())?; + if !args.result.is_object() { + return Err(FunctionCallError::RespondToModel( + "result must be a JSON object".to_string(), + )); + } + let db = required_state_db(&session)?; + let reporting_thread_id = session.conversation_id.to_string(); + let accepted = db + .report_agent_job_item_result( + args.job_id.as_str(), + args.item_id.as_str(), + reporting_thread_id.as_str(), + &args.result, + ) + .await + .map_err(|err| { + let job_id = args.job_id.as_str(); + let item_id = args.item_id.as_str(); + FunctionCallError::RespondToModel(format!( + "failed to record agent job result for {job_id} / {item_id}: {err}" + )) + })?; + if accepted && args.stop.unwrap_or(false) { + let message = "cancelled by worker request"; + let _ = db + .mark_agent_job_cancelled(args.job_id.as_str(), message) + .await; + } + let content = + serde_json::to_string(&ReportAgentJobResultToolResult { accepted }).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize report_agent_job_result result: {err}" + )) + })?; + Ok(ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + success: Some(true), + }) + } +} + +fn required_state_db( + session: &Arc, +) -> Result, FunctionCallError> { + session.state_db().ok_or_else(|| { + FunctionCallError::Fatal( + "sqlite state db is unavailable for this session; enable the sqlite feature" + .to_string(), + ) + }) +} + +async fn build_runner_options( + session: &Arc, + turn: &Arc, + requested_concurrency: Option, +) -> Result { + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth); + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "agent depth limit reached; this session cannot spawn more subagents".to_string(), + )); + } + let max_concurrency = + normalize_concurrency(requested_concurrency, turn.config.agent_max_threads); + let base_instructions = session.get_base_instructions().await; + let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref(), child_depth)?; + Ok(JobRunnerOptions { + max_concurrency, + spawn_config, + }) +} + +fn normalize_concurrency(requested: Option, max_threads: Option) -> usize { + let requested = requested.unwrap_or(DEFAULT_AGENT_JOB_CONCURRENCY).max(1); + let requested = requested.min(MAX_AGENT_JOB_CONCURRENCY); + if let Some(max_threads) = max_threads { + requested.min(max_threads.max(1)) + } else { + requested + } +} + +fn normalize_max_runtime_seconds(requested: Option) -> Result, FunctionCallError> { + let Some(requested) = requested else { + return Ok(None); + }; + if requested == 0 { + return Err(FunctionCallError::RespondToModel( + "max_runtime_seconds must be >= 1".to_string(), + )); + } + Ok(Some(requested)) +} + +async fn run_agent_job_loop( + session: Arc, + turn: Arc, + db: Arc, + job_id: String, + options: JobRunnerOptions, +) -> anyhow::Result<()> { + let job = db + .get_agent_job(job_id.as_str()) + .await? + .ok_or_else(|| anyhow::anyhow!("agent job {job_id} was not found"))?; + let runtime_timeout = job_runtime_timeout(&job); + let mut active_items: HashMap = HashMap::new(); + let mut progress_emitter = JobProgressEmitter::new(); + recover_running_items( + session.clone(), + db.clone(), + job_id.as_str(), + &mut active_items, + runtime_timeout, + ) + .await?; + let initial_progress = db.get_agent_job_progress(job_id.as_str()).await?; + progress_emitter + .maybe_emit(&session, &turn, job_id.as_str(), &initial_progress, true) + .await?; + + let mut cancel_requested = db.is_agent_job_cancelled(job_id.as_str()).await?; + loop { + let mut progressed = false; + + if !cancel_requested && db.is_agent_job_cancelled(job_id.as_str()).await? { + cancel_requested = true; + let _ = session + .notify_background_event( + &turn, + format!("agent job {job_id} cancellation requested; stopping new workers"), + ) + .await; + } + + if !cancel_requested && active_items.len() < options.max_concurrency { + let slots = options.max_concurrency - active_items.len(); + let pending_items = db + .list_agent_job_items( + job_id.as_str(), + Some(codex_state::AgentJobItemStatus::Pending), + Some(slots), + ) + .await?; + for item in pending_items { + let prompt = build_worker_prompt(&job, &item)?; + let items = vec![UserInput::Text { + text: prompt, + text_elements: Vec::new(), + }]; + let thread_id = match session + .services + .agent_control + .spawn_agent( + options.spawn_config.clone(), + items, + Some(SessionSource::SubAgent(SubAgentSource::Other(format!( + "agent_job:{job_id}" + )))), + ) + .await + { + Ok(thread_id) => thread_id, + Err(CodexErr::AgentLimitReached { .. }) => { + db.mark_agent_job_item_pending( + job_id.as_str(), + item.item_id.as_str(), + None, + ) + .await?; + break; + } + Err(err) => { + let error_message = format!("failed to spawn worker: {err}"); + db.mark_agent_job_item_failed( + job_id.as_str(), + item.item_id.as_str(), + error_message.as_str(), + ) + .await?; + progressed = true; + continue; + } + }; + let assigned = db + .mark_agent_job_item_running_with_thread( + job_id.as_str(), + item.item_id.as_str(), + thread_id.to_string().as_str(), + ) + .await?; + if !assigned { + let _ = session + .services + .agent_control + .shutdown_agent(thread_id) + .await; + continue; + } + active_items.insert( + thread_id, + ActiveJobItem { + item_id: item.item_id.clone(), + started_at: Instant::now(), + }, + ); + progressed = true; + } + } + + if reap_stale_active_items( + session.clone(), + db.clone(), + job_id.as_str(), + &mut active_items, + runtime_timeout, + ) + .await? + { + progressed = true; + } + + let finished = find_finished_threads(session.clone(), &active_items).await; + if finished.is_empty() { + let progress = db.get_agent_job_progress(job_id.as_str()).await?; + if cancel_requested { + if progress.running_items == 0 && active_items.is_empty() { + break; + } + } else if progress.pending_items == 0 + && progress.running_items == 0 + && active_items.is_empty() + { + break; + } + if !progressed { + tokio::time::sleep(STATUS_POLL_INTERVAL).await; + } + continue; + } + + for (thread_id, item_id) in finished { + finalize_finished_item( + session.clone(), + db.clone(), + job_id.as_str(), + item_id.as_str(), + thread_id, + ) + .await?; + active_items.remove(&thread_id); + let progress = db.get_agent_job_progress(job_id.as_str()).await?; + progress_emitter + .maybe_emit(&session, &turn, job_id.as_str(), &progress, false) + .await?; + } + } + + let progress = db.get_agent_job_progress(job_id.as_str()).await?; + if let Err(err) = export_job_csv_snapshot(db.clone(), &job).await { + let message = format!("auto-export failed: {err}"); + db.mark_agent_job_failed(job_id.as_str(), message.as_str()) + .await?; + return Ok(()); + } + let cancelled = cancel_requested || db.is_agent_job_cancelled(job_id.as_str()).await?; + if cancelled { + let pending_items = progress.pending_items; + let message = + format!("agent job {job_id} cancelled with {pending_items} unprocessed items"); + let _ = session.notify_background_event(&turn, message).await; + progress_emitter + .maybe_emit(&session, &turn, job_id.as_str(), &progress, true) + .await?; + return Ok(()); + } + if progress.failed_items > 0 { + let failed_items = progress.failed_items; + let message = format!("agent job completed with {failed_items} failed items"); + let _ = session.notify_background_event(&turn, message).await; + } + db.mark_agent_job_completed(job_id.as_str()).await?; + let progress = db.get_agent_job_progress(job_id.as_str()).await?; + progress_emitter + .maybe_emit(&session, &turn, job_id.as_str(), &progress, true) + .await?; + Ok(()) +} + +async fn export_job_csv_snapshot( + db: Arc, + job: &codex_state::AgentJob, +) -> anyhow::Result<()> { + let items = db.list_agent_job_items(job.id.as_str(), None, None).await?; + let csv_content = render_job_csv(job.input_headers.as_slice(), items.as_slice()) + .map_err(|err| anyhow::anyhow!("failed to render job csv for auto-export: {err}"))?; + let output_path = PathBuf::from(job.output_csv_path.clone()); + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&output_path, csv_content).await?; + Ok(()) +} + +async fn recover_running_items( + session: Arc, + db: Arc, + job_id: &str, + active_items: &mut HashMap, + runtime_timeout: Duration, +) -> anyhow::Result<()> { + let running_items = db + .list_agent_job_items(job_id, Some(codex_state::AgentJobItemStatus::Running), None) + .await?; + for item in running_items { + if is_item_stale(&item, runtime_timeout) { + let error_message = format!("worker exceeded max runtime of {runtime_timeout:?}"); + db.mark_agent_job_item_failed(job_id, item.item_id.as_str(), error_message.as_str()) + .await?; + if let Some(assigned_thread_id) = item.assigned_thread_id.as_ref() + && let Ok(thread_id) = ThreadId::from_string(assigned_thread_id.as_str()) + { + let _ = session + .services + .agent_control + .shutdown_agent(thread_id) + .await; + } + continue; + } + let Some(assigned_thread_id) = item.assigned_thread_id.clone() else { + db.mark_agent_job_item_failed( + job_id, + item.item_id.as_str(), + "running item is missing assigned_thread_id", + ) + .await?; + continue; + }; + let thread_id = match ThreadId::from_string(assigned_thread_id.as_str()) { + Ok(thread_id) => thread_id, + Err(err) => { + let error_message = format!("invalid assigned_thread_id: {err:?}"); + db.mark_agent_job_item_failed( + job_id, + item.item_id.as_str(), + error_message.as_str(), + ) + .await?; + continue; + } + }; + if is_final(&session.services.agent_control.get_status(thread_id).await) { + finalize_finished_item( + session.clone(), + db.clone(), + job_id, + item.item_id.as_str(), + thread_id, + ) + .await?; + } else { + active_items.insert( + thread_id, + ActiveJobItem { + item_id: item.item_id.clone(), + started_at: started_at_from_item(&item), + }, + ); + } + } + Ok(()) +} + +async fn find_finished_threads( + session: Arc, + active_items: &HashMap, +) -> Vec<(ThreadId, String)> { + let mut finished = Vec::new(); + for (thread_id, item) in active_items { + if is_final(&session.services.agent_control.get_status(*thread_id).await) { + finished.push((*thread_id, item.item_id.clone())); + } + } + finished +} + +async fn reap_stale_active_items( + session: Arc, + db: Arc, + job_id: &str, + active_items: &mut HashMap, + runtime_timeout: Duration, +) -> anyhow::Result { + let mut stale = Vec::new(); + for (thread_id, item) in active_items.iter() { + if item.started_at.elapsed() >= runtime_timeout { + stale.push((*thread_id, item.item_id.clone())); + } + } + if stale.is_empty() { + return Ok(false); + } + for (thread_id, item_id) in stale { + let error_message = format!("worker exceeded max runtime of {runtime_timeout:?}"); + db.mark_agent_job_item_failed(job_id, item_id.as_str(), error_message.as_str()) + .await?; + let _ = session + .services + .agent_control + .shutdown_agent(thread_id) + .await; + active_items.remove(&thread_id); + } + Ok(true) +} + +async fn finalize_finished_item( + session: Arc, + db: Arc, + job_id: &str, + item_id: &str, + thread_id: ThreadId, +) -> anyhow::Result<()> { + let mut item = db + .get_agent_job_item(job_id, item_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!("job item not found for finalization: {job_id}/{item_id}") + })?; + if item.result_json.is_none() { + tokio::time::sleep(Duration::from_millis(250)).await; + item = db + .get_agent_job_item(job_id, item_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!("job item not found after grace period: {job_id}/{item_id}") + })?; + } + if item.result_json.is_some() { + if !db.mark_agent_job_item_completed(job_id, item_id).await? { + db.mark_agent_job_item_failed( + job_id, + item_id, + "worker reported result but item could not transition to completed", + ) + .await?; + } + } else { + db.mark_agent_job_item_failed( + job_id, + item_id, + "worker finished without calling report_agent_job_result", + ) + .await?; + } + let _ = session + .services + .agent_control + .shutdown_agent(thread_id) + .await; + Ok(()) +} + +fn build_worker_prompt( + job: &codex_state::AgentJob, + item: &codex_state::AgentJobItem, +) -> anyhow::Result { + let job_id = job.id.as_str(); + let item_id = item.item_id.as_str(); + let instruction = render_instruction_template(job.instruction.as_str(), &item.row_json); + let output_schema = job + .output_schema_json + .as_ref() + .map(serde_json::to_string_pretty) + .transpose()? + .unwrap_or_else(|| "{}".to_string()); + let row_json = serde_json::to_string_pretty(&item.row_json)?; + Ok(format!( + "You are processing one item for a generic agent job.\n\ +Job ID: {job_id}\n\ +Item ID: {item_id}\n\n\ +Task instruction:\n\ +{instruction}\n\n\ +Input row (JSON):\n\ +{row_json}\n\n\ +Expected result schema (JSON Schema or {{}}):\n\ +{output_schema}\n\n\ +You MUST call the `report_agent_job_result` tool exactly once with:\n\ +1. `job_id` = \"{job_id}\"\n\ +2. `item_id` = \"{item_id}\"\n\ +3. `result` = a JSON object that contains your analysis result for this row.\n\n\ +If you need to stop the job early, include `stop` = true in the tool call.\n\n\ +After the tool call succeeds, stop.", + )) +} + +fn render_instruction_template(instruction: &str, row_json: &Value) -> String { + const OPEN_BRACE_SENTINEL: &str = "__CODEX_OPEN_BRACE__"; + const CLOSE_BRACE_SENTINEL: &str = "__CODEX_CLOSE_BRACE__"; + + let mut rendered = instruction + .replace("{{", OPEN_BRACE_SENTINEL) + .replace("}}", CLOSE_BRACE_SENTINEL); + let Some(row) = row_json.as_object() else { + return rendered + .replace(OPEN_BRACE_SENTINEL, "{") + .replace(CLOSE_BRACE_SENTINEL, "}"); + }; + for (key, value) in row { + let placeholder = format!("{{{key}}}"); + let replacement = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + rendered = rendered.replace(placeholder.as_str(), replacement.as_str()); + } + rendered + .replace(OPEN_BRACE_SENTINEL, "{") + .replace(CLOSE_BRACE_SENTINEL, "}") +} + +fn ensure_unique_headers(headers: &[String]) -> Result<(), FunctionCallError> { + let mut seen = HashSet::new(); + for header in headers { + if !seen.insert(header) { + return Err(FunctionCallError::RespondToModel(format!( + "csv header {header} is duplicated" + ))); + } + } + Ok(()) +} + +fn job_runtime_timeout(job: &codex_state::AgentJob) -> Duration { + job.max_runtime_seconds + .map(Duration::from_secs) + .unwrap_or(DEFAULT_AGENT_JOB_ITEM_TIMEOUT) +} + +fn started_at_from_item(item: &codex_state::AgentJobItem) -> Instant { + let now = chrono::Utc::now(); + let age = now.signed_duration_since(item.updated_at); + if let Ok(age) = age.to_std() { + Instant::now().checked_sub(age).unwrap_or_else(Instant::now) + } else { + Instant::now() + } +} + +fn is_item_stale(item: &codex_state::AgentJobItem, runtime_timeout: Duration) -> bool { + let now = chrono::Utc::now(); + if let Ok(age) = now.signed_duration_since(item.updated_at).to_std() { + age >= runtime_timeout + } else { + false + } +} + +fn default_output_csv_path(input_csv_path: &Path, job_id: &str) -> PathBuf { + let stem = input_csv_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("agent_job_output"); + let job_suffix = &job_id[..8]; + input_csv_path.with_file_name(format!("{stem}.agent-job-{job_suffix}.csv")) +} + +fn parse_csv(content: &str) -> Result<(Vec, Vec>), String> { + let mut reader = csv::ReaderBuilder::new() + .has_headers(true) + .flexible(true) + .from_reader(content.as_bytes()); + let headers_record = reader.headers().map_err(|err| err.to_string())?; + let mut headers: Vec = headers_record.iter().map(str::to_string).collect(); + if let Some(first) = headers.first_mut() { + *first = first.trim_start_matches('\u{feff}').to_string(); + } + let mut rows = Vec::new(); + for record in reader.records() { + let record = record.map_err(|err| err.to_string())?; + let row: Vec = record.iter().map(str::to_string).collect(); + if row.iter().all(std::string::String::is_empty) { + continue; + } + rows.push(row); + } + Ok((headers, rows)) +} + +fn render_job_csv( + headers: &[String], + items: &[codex_state::AgentJobItem], +) -> Result { + let mut csv = String::new(); + let mut output_headers = headers.to_vec(); + output_headers.extend([ + "job_id".to_string(), + "item_id".to_string(), + "row_index".to_string(), + "source_id".to_string(), + "status".to_string(), + "attempt_count".to_string(), + "last_error".to_string(), + "result_json".to_string(), + "reported_at".to_string(), + "completed_at".to_string(), + ]); + csv.push_str( + output_headers + .iter() + .map(|header| csv_escape(header.as_str())) + .collect::>() + .join(",") + .as_str(), + ); + csv.push('\n'); + for item in items { + let row_object = item.row_json.as_object().ok_or_else(|| { + let item_id = item.item_id.as_str(); + FunctionCallError::RespondToModel(format!( + "row_json for item {item_id} is not a JSON object" + )) + })?; + let mut row_values = Vec::new(); + for header in headers { + let value = row_object + .get(header) + .map_or_else(String::new, value_to_csv_string); + row_values.push(csv_escape(value.as_str())); + } + row_values.push(csv_escape(item.job_id.as_str())); + row_values.push(csv_escape(item.item_id.as_str())); + row_values.push(csv_escape(item.row_index.to_string().as_str())); + row_values.push(csv_escape( + item.source_id.clone().unwrap_or_default().as_str(), + )); + row_values.push(csv_escape(item.status.as_str())); + row_values.push(csv_escape(item.attempt_count.to_string().as_str())); + row_values.push(csv_escape( + item.last_error.clone().unwrap_or_default().as_str(), + )); + row_values.push(csv_escape( + item.result_json + .as_ref() + .map_or_else(String::new, std::string::ToString::to_string) + .as_str(), + )); + row_values.push(csv_escape( + item.reported_at + .map(|value| value.to_rfc3339()) + .unwrap_or_default() + .as_str(), + )); + row_values.push(csv_escape( + item.completed_at + .map(|value| value.to_rfc3339()) + .unwrap_or_default() + .as_str(), + )); + csv.push_str(row_values.join(",").as_str()); + csv.push('\n'); + } + Ok(csv) +} + +fn value_to_csv_string(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::Array(_) | Value::Object(_) => value.to_string(), + } +} + +fn csv_escape(value: &str) -> String { + if value.contains(',') || value.contains('\n') || value.contains('\r') || value.contains('"') { + let escaped = value.replace('"', "\"\""); + format!("\"{escaped}\"") + } else { + value.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn parse_csv_supports_quotes_and_commas() { + let input = "id,name\n1,\"alpha, beta\"\n2,gamma\n"; + let (headers, rows) = parse_csv(input).expect("csv parse"); + assert_eq!(headers, vec!["id".to_string(), "name".to_string()]); + assert_eq!( + rows, + vec![ + vec!["1".to_string(), "alpha, beta".to_string()], + vec!["2".to_string(), "gamma".to_string()] + ] + ); + } + + #[test] + fn csv_escape_quotes_when_needed() { + assert_eq!(csv_escape("simple"), "simple"); + assert_eq!(csv_escape("a,b"), "\"a,b\""); + assert_eq!(csv_escape("a\"b"), "\"a\"\"b\""); + } + + #[test] + fn render_instruction_template_expands_placeholders_and_escapes_braces() { + let row = json!({ + "path": "src/lib.rs", + "area": "test", + "file path": "docs/readme.md", + }); + let rendered = render_instruction_template( + "Review {path} in {area}. Also see {file path}. Use {{literal}}.", + &row, + ); + assert_eq!( + rendered, + "Review src/lib.rs in test. Also see docs/readme.md. Use {literal}." + ); + } + + #[test] + fn render_instruction_template_leaves_unknown_placeholders() { + let row = json!({ + "path": "src/lib.rs", + }); + let rendered = render_instruction_template("Check {path} then {missing}", &row); + assert_eq!(rendered, "Check src/lib.rs then {missing}"); + } + + #[test] + fn ensure_unique_headers_rejects_duplicates() { + let headers = vec!["path".to_string(), "path".to_string()]; + let Err(err) = ensure_unique_headers(headers.as_slice()) else { + panic!("expected duplicate header error"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("csv header path is duplicated".to_string()) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index ae93327b81..810c5cb1d6 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -86,6 +86,7 @@ impl ToolHandler for ApplyPatchHandler { call_id, tool_name, payload, + .. } = invocation; let patch_input = match payload { diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index ba52a2d011..bdc47a088d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod agent_jobs; pub mod apply_patch; mod dynamic; mod grep_files; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 090810d9f1..8fbbd0d112 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -1,5 +1,6 @@ use crate::agent::AgentStatus; use crate::agent::exceeds_thread_spawn_depth_limit; +use crate::agent::max_thread_spawn_depth; use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; @@ -97,6 +98,7 @@ mod spawn { use crate::agent::role::apply_role_to_config; use crate::agent::exceeds_thread_spawn_depth_limit; + use crate::agent::max_thread_spawn_depth; use crate::agent::next_thread_spawn_depth; use std::sync::Arc; @@ -129,7 +131,8 @@ mod spawn { let prompt = input_preview(&input_items); let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); - if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) { + let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth); + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { return Err(FunctionCallError::RespondToModel( "Agent depth limit reached. Solve the task yourself.".to_string(), )); @@ -345,7 +348,8 @@ mod resume_agent { .await .unwrap_or((None, None)); let child_depth = next_thread_spawn_depth(&turn.session_source); - if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) { + let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth); + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { return Err(FunctionCallError::RespondToModel( "Agent depth limit reached. Solve the task yourself.".to_string(), )); @@ -891,7 +895,7 @@ fn input_preview(items: &[UserInput]) -> String { parts.join("\n") } -fn build_agent_spawn_config( +pub(crate) fn build_agent_spawn_config( base_instructions: &BaseInstructions, turn: &TurnContext, child_depth: i32, @@ -948,7 +952,8 @@ fn apply_spawn_agent_runtime_overrides( fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); - if exceeds_thread_spawn_depth_limit(child_depth + 1, config.agent_max_depth) { + let max_depth = max_thread_spawn_depth(config.agent_max_spawn_depth); + if exceeds_thread_spawn_depth_limit(child_depth + 1, max_depth) { config.features.disable(Feature::Collab); } } @@ -959,6 +964,7 @@ mod tests { use crate::AuthManager; use crate::CodexAuth; use crate::ThreadManager; + use crate::agent::max_thread_spawn_depth; use crate::built_in_model_providers; use crate::codex::make_session_and_context; use crate::config::DEFAULT_AGENT_MAX_DEPTH; @@ -998,6 +1004,7 @@ mod tests { call_id: "call-1".to_string(), tool_name: tool_name.to_string(), payload, + source: crate::tools::router::ToolCallSource::Direct, } } @@ -1259,9 +1266,10 @@ mod tests { let manager = thread_manager(); session.services.agent_control = manager.agent_control(); + let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, - depth: DEFAULT_AGENT_MAX_DEPTH, + depth: max_depth, agent_nickname: None, agent_role: None, }); @@ -1689,9 +1697,10 @@ mod tests { let manager = thread_manager(); session.services.agent_control = manager.agent_control(); + let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, - depth: DEFAULT_AGENT_MAX_DEPTH, + depth: max_depth, agent_nickname: None, agent_role: None, }); diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 79db57e2e6..0a0bb53476 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -14,6 +14,8 @@ use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; use crate::shell::Shell; +use crate::skills::SKILL_APPROVAL_DECLINED_MESSAGE; +use crate::skills::ensure_skill_approval_for_command; use crate::skills::maybe_emit_implicit_skill_invocation; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -66,6 +68,8 @@ impl ShellHandler { ) -> ExecParams { ExecParams { command: params.command.clone(), + original_command: shlex::try_join(params.command.iter().map(String::as_str)) + .unwrap_or_else(|_| params.command.join(" ")), cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), @@ -116,6 +120,7 @@ impl ShellCommandHandler { Ok(ExecParams { command, + original_command: params.command.clone(), cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), @@ -171,6 +176,7 @@ impl ToolHandler for ShellHandler { call_id, tool_name, payload, + .. } = invocation; match payload { @@ -256,6 +262,7 @@ impl ToolHandler for ShellCommandHandler { call_id, tool_name, payload, + .. } = invocation; let ToolPayload::Function { arguments } = payload else { @@ -265,13 +272,6 @@ impl ToolHandler for ShellCommandHandler { }; let params: ShellCommandToolCallParams = parse_arguments(&arguments)?; - maybe_emit_implicit_skill_invocation( - session.as_ref(), - turn.as_ref(), - ¶ms.command, - params.workdir.as_deref(), - ) - .await; let prefix_rule = params.prefix_rule.clone(); let exec_params = Self::to_exec_params( ¶ms, @@ -348,6 +348,28 @@ impl ShellHandler { "approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}" ))); } + let original_command = exec_params.original_command.as_str(); + if !ensure_skill_approval_for_command( + session.as_ref(), + turn.as_ref(), + &call_id, + original_command, + exec_params.cwd.as_path(), + ) + .await + { + return Err(FunctionCallError::RespondToModel( + SKILL_APPROVAL_DECLINED_MESSAGE.to_string(), + )); + } + let workdir = exec_params.cwd.to_string_lossy().into_owned(); + maybe_emit_implicit_skill_invocation( + session.as_ref(), + turn.as_ref(), + original_command, + Some(workdir.as_str()), + ) + .await; // Intercept apply_patch if present. if let Some(output) = intercept_apply_patch( diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 6337cef3e0..ea05f736e7 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -8,6 +8,7 @@ use tokio::fs; use crate::function_tool::FunctionCallError; use crate::protocol::EventMsg; use crate::protocol::ViewImageToolCallEvent; +use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -15,6 +16,7 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseInputItem; use codex_protocol::models::local_image_content_items_with_label_number; pub struct ViewImageHandler; @@ -50,6 +52,7 @@ impl ToolHandler for ViewImageHandler { turn, payload, call_id, + source, .. } = invocation; @@ -81,7 +84,26 @@ impl ToolHandler for ViewImageHandler { } let event_path = abs_path.clone(); - let content = local_image_content_items_with_label_number(&abs_path, None) + let content = local_image_content_items_with_label_number(&abs_path, None); + if source == ToolCallSource::JsRepl + && content + .iter() + .any(|item| matches!(item, ContentItem::InputImage { .. })) + { + let input_item = ResponseInputItem::Message { + role: "user".to_string(), + content: content.clone(), + }; + if session + .inject_response_items(vec![input_item]) + .await + .is_err() + { + tracing::warn!("view_image could not find an active turn to attach image input"); + } + } + + let content = content .into_iter() .map(|item| match item { ContentItem::InputText { text } => { diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index fe0827f4f7..d95a2c49a7 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -22,6 +22,8 @@ use std::collections::HashMap; use std::sync::Arc; use tracing::instrument; +pub use crate::tools::context::ToolCallSource; + #[derive(Clone, Debug)] pub struct ToolCall { pub tool_name: String, @@ -29,12 +31,6 @@ pub struct ToolCall { pub payload: ToolPayload, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ToolCallSource { - Direct, - JsRepl, -} - pub struct ToolRouter { registry: ToolRegistry, specs: Vec, @@ -179,6 +175,7 @@ impl ToolRouter { call_id, tool_name, payload, + source, }; match self.registry.dispatch(invocation).await { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 2fd7f49e23..fcc373acb1 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -107,6 +107,16 @@ pub(super) async fn try_run_zsh_fork( justification, arg0, }; + let main_execve_wrapper_exe = ctx + .session + .services + .main_execve_wrapper_exe + .clone() + .ok_or_else(|| { + ToolError::Rejected( + "zsh fork feature enabled, but execve wrapper is not configured".to_string(), + ) + })?; let exec_result = run_escalate_server( ExecParams { command: script, @@ -115,7 +125,7 @@ pub(super) async fn try_run_zsh_fork( login: Some(login), }, shell_zsh_path.clone(), - shell_execve_wrapper().map_err(|err| ToolError::Rejected(format!("{err}")))?, + main_execve_wrapper_exe, exec_policy.clone(), ShellPolicyFactory::new(CoreShellActionProvider { policy: Arc::clone(&exec_policy), @@ -334,34 +344,6 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { } } -// TODO(mbolin): This should be passed down from codex-arg0 like codex_linux_sandbox_exe. -fn shell_execve_wrapper() -> anyhow::Result { - const EXECVE_WRAPPER: &str = "codex-execve-wrapper"; - - if let Some(path) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&path) { - let candidate = dir.join(EXECVE_WRAPPER); - if candidate.is_file() { - return Ok(candidate); - } - } - } - - let exe = std::env::current_exe()?; - let sibling = exe - .parent() - .map(|parent| parent.join(EXECVE_WRAPPER)) - .ok_or_else(|| anyhow::anyhow!("failed to determine codex-execve-wrapper path"))?; - if sibling.is_file() { - return Ok(sibling); - } - - Err(anyhow::anyhow!( - "failed to locate {EXECVE_WRAPPER} in PATH or next to current executable ({})", - exe.display() - )) -} - #[derive(Debug, Eq, PartialEq)] struct ParsedShellCommand { script: String, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index ea9ccb3ea9..3c9ab4bf3a 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -9,6 +9,7 @@ use crate::mcp_connection_manager::ToolInfo; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT; use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; +use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; @@ -22,6 +23,8 @@ use codex_protocol::models::VIEW_IMAGE_TOOL_NAME; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; @@ -53,12 +56,15 @@ pub(crate) struct ToolsConfig { pub collab_tools: bool, pub collaboration_modes_tools: bool, pub experimental_supported_tools: Vec, + pub agent_jobs_tools: bool, + pub agent_jobs_worker_tools: bool, } pub(crate) struct ToolsConfigParams<'a> { pub(crate) model_info: &'a ModelInfo, pub(crate) features: &'a Features, pub(crate) web_search_mode: Option, + pub(crate) session_source: SessionSource, } impl ToolsConfig { @@ -67,14 +73,16 @@ impl ToolsConfig { model_info, features, web_search_mode, + session_source, } = params; let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_js_repl = features.enabled(Feature::JsRepl); let include_js_repl_tools_only = include_js_repl && features.enabled(Feature::JsReplToolsOnly); let include_collab_tools = features.enabled(Feature::Collab); - let include_collaboration_modes_tools = true; + let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes); let include_search_tool = features.enabled(Feature::Apps); + let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite); let request_permission_enabled = features.enabled(Feature::RequestPermissions); let shell_command_backend = if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { @@ -110,6 +118,13 @@ impl ToolsConfig { } }; + let agent_jobs_worker_tools = include_agent_jobs + && matches!( + session_source, + SessionSource::SubAgent(SubAgentSource::Other(label)) + if label.starts_with("agent_job:") + ); + Self { shell_type, shell_command_backend, @@ -124,6 +139,8 @@ impl ToolsConfig { collab_tools: include_collab_tools, collaboration_modes_tools: include_collaboration_modes_tools, experimental_supported_tools: model_info.experimental_supported_tools.clone(), + agent_jobs_tools: include_agent_jobs, + agent_jobs_worker_tools, } } @@ -623,6 +640,131 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { }) } +fn create_spawn_agents_on_csv_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "csv_path".to_string(), + JsonSchema::String { + description: Some("Path to the CSV file containing input rows.".to_string()), + }, + ); + properties.insert( + "instruction".to_string(), + JsonSchema::String { + description: Some( + "Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row." + .to_string(), + ), + }, + ); + properties.insert( + "id_column".to_string(), + JsonSchema::String { + description: Some("Optional column name to use as stable item id.".to_string()), + }, + ); + properties.insert( + "output_csv_path".to_string(), + JsonSchema::String { + description: Some("Optional output CSV path for exported results.".to_string()), + }, + ); + properties.insert( + "max_concurrency".to_string(), + JsonSchema::Number { + description: Some( + "Maximum concurrent workers for this job. Defaults to 16 and is capped by config." + .to_string(), + ), + }, + ); + properties.insert( + "max_workers".to_string(), + JsonSchema::Number { + description: Some( + "Alias for max_concurrency. Set to 1 to run sequentially.".to_string(), + ), + }, + ); + properties.insert( + "max_runtime_seconds".to_string(), + JsonSchema::Number { + description: Some( + "Maximum runtime per worker before it is failed. Defaults to 1800 seconds." + .to_string(), + ), + }, + ); + properties.insert( + "output_schema".to_string(), + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + ); + ToolSpec::Function(ResponsesApiTool { + name: "spawn_agents_on_csv".to_string(), + description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)." + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["csv_path".to_string(), "instruction".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + +fn create_report_agent_job_result_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "job_id".to_string(), + JsonSchema::String { + description: Some("Identifier of the job.".to_string()), + }, + ); + properties.insert( + "item_id".to_string(), + JsonSchema::String { + description: Some("Identifier of the job item.".to_string()), + }, + ); + properties.insert( + "result".to_string(), + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + ); + properties.insert( + "stop".to_string(), + JsonSchema::Boolean { + description: Some( + "Optional. When true, cancels the remaining job items after this result is recorded." + .to_string(), + ), + }, + ); + ToolSpec::Function(ResponsesApiTool { + name: "report_agent_job_result".to_string(), + description: + "Worker-only tool to report a result for an agent job item. Main agents should not call this." + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec![ + "job_id".to_string(), + "item_id".to_string(), + "result".to_string(), + ]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_send_input_tool() -> ToolSpec { let properties = BTreeMap::from([ ( @@ -1670,6 +1812,16 @@ pub(crate) fn build_specs( builder.register_handler("close_agent", multi_agent_handler); } + if config.agent_jobs_tools { + let agent_jobs_handler = Arc::new(BatchJobHandler); + builder.push_spec(create_spawn_agents_on_csv_tool()); + builder.register_handler("spawn_agents_on_csv", agent_jobs_handler.clone()); + if config.agent_jobs_worker_tools { + builder.push_spec(create_report_agent_job_result_tool()); + builder.register_handler("report_agent_job_result", agent_jobs_handler); + } + } + if let Some(mcp_tools) = mcp_tools { let mut entries: Vec<(String, rmcp::model::Tool)> = mcp_tools.into_iter().collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); @@ -1870,6 +2022,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&config, None, None, &[]).build(); @@ -1928,10 +2081,42 @@ mod tests { let mut features = Features::with_defaults(); features.enable(Feature::Collab); features.enable(Feature::CollaborationModes); + features.enable(Feature::Sqlite); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names( + &tools, + &[ + "spawn_agent", + "send_input", + "wait", + "close_agent", + "spawn_agents_on_csv", + ], + ); + } + + #[test] + fn test_build_specs_agent_job_worker_tools_enabled() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Collab); + features.enable(Feature::CollaborationModes); + features.enable(Feature::Sqlite); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::SubAgent(SubAgentSource::Other( + "agent_job:test".to_string(), + )), }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert_contains_tool_names( @@ -1942,10 +2127,62 @@ mod tests { "resume_agent", "wait", "close_agent", + "spawn_agents_on_csv", + "report_agent_job_result", ], ); } + #[test] + fn request_user_input_requires_collaboration_modes_feature() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.disable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert!( + !tools.iter().any(|t| t.spec.name() == "request_user_input"), + "request_user_input should be disabled when collaboration_modes feature is off" + ); + + features.enable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names(&tools, &["request_user_input"]); + } + + #[test] + fn get_memory_requires_feature_flag() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.disable(Feature::MemoryTool); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert!( + !tools.iter().any(|t| t.spec.name() == "get_memory"), + "get_memory should be disabled when memory_tool feature is off" + ); + } + #[test] fn js_repl_requires_feature_flag() { let config = test_config(); @@ -1957,6 +2194,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); @@ -1982,6 +2220,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]); @@ -2013,6 +2252,7 @@ mod tests { model_info: &model_info, features, web_search_mode, + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let tool_names = tools.iter().map(|t| t.spec.name()).collect::>(); @@ -2046,6 +2286,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); @@ -2069,6 +2310,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); @@ -2092,6 +2334,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); @@ -2115,6 +2358,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); @@ -2314,6 +2558,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); @@ -2337,6 +2582,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, }); assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); @@ -2358,6 +2604,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); @@ -2382,6 +2629,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); @@ -2413,6 +2661,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs( &tools_config, @@ -2499,6 +2748,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); // Intentionally construct a map with keys that would sort alphabetically. @@ -2544,6 +2794,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs( @@ -2611,6 +2862,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs( @@ -2665,6 +2917,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs( @@ -2716,6 +2969,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs( @@ -2769,6 +3023,7 @@ mod tests { model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs( @@ -2901,6 +3156,7 @@ Examples of valid command strings: model_info: &model_info, features: &features, web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, }); let (tools, _) = build_specs( &tools_config, diff --git a/codex-rs/core/templates/memories/read_path.md b/codex-rs/core/templates/memories/read_path.md index b472a66c4c..7fd38ca50e 100644 --- a/codex-rs/core/templates/memories/read_path.md +++ b/codex-rs/core/templates/memories/read_path.md @@ -52,48 +52,9 @@ When to update memory: - When user explicitly asks to remember/update memory, revise memory_summary.md and/or MEMORY.md. -Memory citation requirements: - -- If ANY relevant memory files were used: append exactly one - `` block as the VERY LAST content of the final reply. - Normal responses should include the answer first, then append the - `` block at the end. -- Use this exact structure for programmatic parsing: - - -MEMORY.md:234-236|note=[responsesapi citation extraction code pointer] -rollout_summaries/2026-02-17T21-23-02-LN3m-weekly_memory_report_pivot_from_git_history.md:10-12|note=[weekly report format] - - -019c6e27-e55b-73d1-87d8-4e01f1f75043 -019c7714-3b77-74d1-9866-e1f484aae2ab - - -- `citation_entries` is for rendering: - - one citation entry per line - - format: `:-|note=[]` - - use file paths relative to the memory base path (for example, `MEMORY.md`, - `rollout_summaries/...`, `skills/...`) - - only cite files actually used under the memory base path (do not cite - workspace files as memory citations) - - if you used `MEMORY.md` and then a rollout summary/skill file, cite both - - list entries in order of importance (most important first) - - `note` should be short, single-line, and use simple characters only (avoid - unusual symbols, no newlines) -- `thread_ids` is for us to track what previous rollouts you find useful: - - include one thread id per line - - thread ids should look like UUIDs (for example, - `019c6e27-e55b-73d1-87d8-4e01f1f75043`) - - include unique ids only; do not repeat ids - - an empty `` section is allowed if no thread ids are available - - you can find thread ids in rollout summary files and MEMORY.md - - do not include file paths or notes in this section -- Never include memory citations inside pull-request messages. -- Never cite blank lines; double-check ranges. - ========= MEMORY_SUMMARY BEGINS ========= {{ memory_summary }} ========= MEMORY_SUMMARY ENDS ========= When memory is likely relevant, start with the quick memory pass above before -deep repo exploration. \ No newline at end of file +deep repo exploration. diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 8205909238..173db5ca77 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -215,6 +215,14 @@ impl TestCodexBuilder { } if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { config.codex_linux_sandbox_exe = Some(path); + } else if let Ok(exe) = std::env::current_exe() + && let Some(path) = exe + .parent() + .and_then(|parent| parent.parent()) + .map(|parent| parent.join("codex")) + && path.is_file() + { + config.codex_linux_sandbox_exe = Some(path); } let mut mutators = vec![]; diff --git a/codex-rs/core/tests/suite/agent_jobs.rs b/codex-rs/core/tests/suite/agent_jobs.rs new file mode 100644 index 0000000000..5708f400ad --- /dev/null +++ b/codex-rs/core/tests/suite/agent_jobs.rs @@ -0,0 +1,424 @@ +use anyhow::Result; +use codex_core::features::Feature; +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::sse; +use core_test_support::responses::sse_response; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use regex_lite::Regex; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use wiremock::Mock; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path_regex; + +struct AgentJobsResponder { + spawn_args_json: String, + seen_main: AtomicBool, + call_counter: AtomicUsize, +} + +impl AgentJobsResponder { + fn new(spawn_args_json: String) -> Self { + Self { + spawn_args_json, + seen_main: AtomicBool::new(false), + call_counter: AtomicUsize::new(0), + } + } +} + +struct StopAfterFirstResponder { + spawn_args_json: String, + seen_main: AtomicBool, + worker_calls: Arc, +} + +impl StopAfterFirstResponder { + fn new(spawn_args_json: String, worker_calls: Arc) -> Self { + Self { + spawn_args_json, + seen_main: AtomicBool::new(false), + worker_calls, + } + } +} + +impl Respond for StopAfterFirstResponder { + fn respond(&self, request: &wiremock::Request) -> ResponseTemplate { + let body_bytes = decode_body_bytes(request); + let body: Value = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null); + + if has_function_call_output(&body) { + return sse_response(sse(vec![ + ev_response_created("resp-tool"), + ev_completed("resp-tool"), + ])); + } + + if let Some((job_id, item_id)) = extract_job_and_item(&body) { + let call_index = self.worker_calls.fetch_add(1, Ordering::SeqCst); + let call_id = format!("call-worker-{call_index}"); + let stop = call_index == 0; + let args = json!({ + "job_id": job_id, + "item_id": item_id, + "result": { "item_id": item_id }, + "stop": stop, + }); + let args_json = serde_json::to_string(&args).unwrap_or_else(|err| { + panic!("worker args serialize: {err}"); + }); + return sse_response(sse(vec![ + ev_response_created("resp-worker"), + ev_function_call(&call_id, "report_agent_job_result", &args_json), + ev_completed("resp-worker"), + ])); + } + + if !self.seen_main.swap(true, Ordering::SeqCst) { + return sse_response(sse(vec![ + ev_response_created("resp-main"), + ev_function_call("call-spawn", "spawn_agents_on_csv", &self.spawn_args_json), + ev_completed("resp-main"), + ])); + } + + sse_response(sse(vec![ + ev_response_created("resp-default"), + ev_completed("resp-default"), + ])) + } +} + +impl Respond for AgentJobsResponder { + fn respond(&self, request: &wiremock::Request) -> ResponseTemplate { + let body_bytes = decode_body_bytes(request); + let body: Value = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null); + + if has_function_call_output(&body) { + return sse_response(sse(vec![ + ev_response_created("resp-tool"), + ev_completed("resp-tool"), + ])); + } + + if let Some((job_id, item_id)) = extract_job_and_item(&body) { + let call_id = format!( + "call-worker-{}", + self.call_counter.fetch_add(1, Ordering::SeqCst) + ); + let args = json!({ + "job_id": job_id, + "item_id": item_id, + "result": { "item_id": item_id } + }); + let args_json = serde_json::to_string(&args).unwrap_or_else(|err| { + panic!("worker args serialize: {err}"); + }); + return sse_response(sse(vec![ + ev_response_created("resp-worker"), + ev_function_call(&call_id, "report_agent_job_result", &args_json), + ev_completed("resp-worker"), + ])); + } + + if !self.seen_main.swap(true, Ordering::SeqCst) { + return sse_response(sse(vec![ + ev_response_created("resp-main"), + ev_function_call("call-spawn", "spawn_agents_on_csv", &self.spawn_args_json), + ev_completed("resp-main"), + ])); + } + + sse_response(sse(vec![ + ev_response_created("resp-default"), + ev_completed("resp-default"), + ])) + } +} + +fn decode_body_bytes(request: &wiremock::Request) -> Vec { + let Some(encoding) = request + .headers + .get("content-encoding") + .and_then(|value| value.to_str().ok()) + else { + return request.body.clone(); + }; + if encoding + .split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) + { + zstd::stream::decode_all(std::io::Cursor::new(&request.body)) + .unwrap_or_else(|_| request.body.clone()) + } else { + request.body.clone() + } +} + +fn has_function_call_output(body: &Value) -> bool { + body.get("input") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + }) + }) +} + +fn extract_job_and_item(body: &Value) -> Option<(String, String)> { + let texts = message_input_texts(body); + let mut combined = texts.join("\n"); + if let Some(instructions) = body.get("instructions").and_then(Value::as_str) { + combined.push('\n'); + combined.push_str(instructions); + } + if !combined.contains("You are processing one item for a generic agent job.") { + return None; + } + let job_id = Regex::new(r"Job ID:\s*([^\n]+)") + .ok()? + .captures(&combined) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().trim().to_string())?; + let item_id = Regex::new(r"Item ID:\s*([^\n]+)") + .ok()? + .captures(&combined) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().trim().to_string())?; + Some((job_id, item_id)) +} + +fn message_input_texts(body: &Value) -> Vec { + let Some(items) = body.get("input").and_then(Value::as_array) else { + return Vec::new(); + }; + items + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("message")) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text")) + .filter_map(|span| span.get("text").and_then(Value::as_str)) + .map(str::to_string) + .collect() +} + +fn parse_simple_csv_line(line: &str) -> Vec { + line.split(',').map(str::to_string).collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn report_agent_job_result_rejects_wrong_thread() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::Collab); + config.features.enable(Feature::Sqlite); + }); + let test = builder.build(&server).await?; + + let input_path = test.cwd_path().join("agent_jobs_wrong_thread.csv"); + let output_path = test.cwd_path().join("agent_jobs_wrong_thread_out.csv"); + fs::write(&input_path, "path\nfile-1\n")?; + + let args = json!({ + "csv_path": input_path.display().to_string(), + "instruction": "Return {path}", + "output_csv_path": output_path.display().to_string(), + }); + let args_json = serde_json::to_string(&args)?; + + let responder = AgentJobsResponder::new(args_json); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(responder) + .mount(&server) + .await; + + test.submit_turn("run job").await?; + + let db = test.codex.state_db().expect("state db"); + let output = fs::read_to_string(&output_path)?; + let rows: Vec<&str> = output.lines().skip(1).collect(); + assert_eq!(rows.len(), 1); + let job_id = rows + .first() + .and_then(|line| { + parse_simple_csv_line(line) + .iter() + .find(|value| value.len() == 36) + .cloned() + }) + .expect("job_id from csv"); + let job = db.get_agent_job(job_id.as_str()).await?.expect("job"); + let items = db + .list_agent_job_items(job.id.as_str(), None, Some(10)) + .await?; + let item = items.first().expect("item"); + let wrong_thread_id = "00000000-0000-0000-0000-000000000000"; + let accepted = db + .report_agent_job_item_result( + job.id.as_str(), + item.item_id.as_str(), + wrong_thread_id, + &json!({ "wrong": true }), + ) + .await?; + assert!(!accepted); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawn_agents_on_csv_runs_and_exports() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::Collab); + config.features.enable(Feature::Sqlite); + }); + let test = builder.build(&server).await?; + + let input_path = test.cwd_path().join("agent_jobs_input.csv"); + let output_path = test.cwd_path().join("agent_jobs_output.csv"); + fs::write(&input_path, "path,area\nfile-1,test\nfile-2,test\n")?; + + let args = json!({ + "csv_path": input_path.display().to_string(), + "instruction": "Return {path}", + "output_csv_path": output_path.display().to_string(), + }); + let args_json = serde_json::to_string(&args)?; + + let responder = AgentJobsResponder::new(args_json); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(responder) + .mount(&server) + .await; + + test.submit_turn("run batch job").await?; + + let output = fs::read_to_string(&output_path)?; + assert!(output.contains("result_json")); + assert!(output.contains("item_id")); + assert!(output.contains("\"item_id\"")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawn_agents_on_csv_dedupes_item_ids() -> Result<()> { + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::Collab); + config.features.enable(Feature::Sqlite); + }); + let test = builder.build(&server).await?; + + let input_path = test.cwd_path().join("agent_jobs_dupe.csv"); + let output_path = test.cwd_path().join("agent_jobs_dupe_out.csv"); + fs::write(&input_path, "id,path\nfoo,alpha\nfoo,beta\n")?; + + let args = json!({ + "csv_path": input_path.display().to_string(), + "instruction": "Return {path}", + "id_column": "id", + "output_csv_path": output_path.display().to_string(), + }); + let args_json = serde_json::to_string(&args)?; + + let responder = AgentJobsResponder::new(args_json); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(responder) + .mount(&server) + .await; + + test.submit_turn("run batch job with duplicate ids").await?; + + let output = fs::read_to_string(&output_path)?; + let mut lines = output.lines(); + let headers = lines.next().expect("csv headers"); + let header_cols = parse_simple_csv_line(headers); + let item_id_index = header_cols + .iter() + .position(|header| header == "item_id") + .expect("item_id column"); + + let mut item_ids = Vec::new(); + for line in lines { + let cols = parse_simple_csv_line(line); + item_ids.push(cols[item_id_index].clone()); + } + item_ids.sort(); + item_ids.dedup(); + assert_eq!(item_ids.len(), 2); + assert!(item_ids.contains(&"foo".to_string())); + assert!(item_ids.contains(&"foo-2".to_string())); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawn_agents_on_csv_stop_halts_future_items() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::Collab); + config.features.enable(Feature::Sqlite); + }); + let test = builder.build(&server).await?; + + let input_path = test.cwd_path().join("agent_jobs_stop.csv"); + let output_path = test.cwd_path().join("agent_jobs_stop_out.csv"); + fs::write(&input_path, "path\nfile-1\nfile-2\nfile-3\n")?; + + let args = json!({ + "csv_path": input_path.display().to_string(), + "instruction": "Return {path}", + "output_csv_path": output_path.display().to_string(), + "max_concurrency": 1, + }); + let args_json = serde_json::to_string(&args)?; + + let worker_calls = Arc::new(AtomicUsize::new(0)); + let responder = StopAfterFirstResponder::new(args_json, worker_calls.clone()); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(responder) + .mount(&server) + .await; + + test.submit_turn("run job").await?; + + let output = fs::read_to_string(&output_path)?; + let rows: Vec<&str> = output.lines().skip(1).collect(); + assert_eq!(rows.len(), 3); + let job_id = rows + .first() + .and_then(|line| { + parse_simple_csv_line(line) + .iter() + .find(|value| value.len() == 36) + .cloned() + }) + .expect("job_id from csv"); + let db = test.codex.state_db().expect("state db"); + let job = db.get_agent_job(job_id.as_str()).await?.expect("job"); + assert_eq!(job.status, codex_state::AgentJobStatus::Cancelled); + let progress = db.get_agent_job_progress(job_id.as_str()).await?; + assert_eq!(progress.total_items, 3); + assert_eq!(progress.completed_items, 1); + assert_eq!(progress.failed_items, 0); + assert_eq!(progress.running_items, 0); + assert_eq!(progress.pending_items, 2); + assert_eq!(worker_calls.load(Ordering::SeqCst), 1); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 6546600681..b6f78267b6 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -33,6 +33,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let requested_model = "custom/gpt-5.2-codex"; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let TestCodex { + codex, cwd, config, .. + } = test_codex() + .with_model(requested_model) + .build(&server) + .await?; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "check namespaced model metadata".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: config.permissions.approval_policy.value(), + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: requested_model.to_string(), + effort: None, + summary: config.model_reasoning_summary, + collaboration_mode: None, + personality: None, + }) + .await?; + + let mut fallback_warning_count = 0; + loop { + let event = wait_for_event(&codex, |_| true).await; + match event { + EventMsg::Warning(warning) + if warning.message.contains("Defaulting to fallback metadata") => + { + fallback_warning_count += 1; + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + let body = response_mock.single_request().body_json(); + assert_eq!(body["model"].as_str(), Some(requested_model)); + assert_eq!(fallback_warning_count, 0); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 2a8017b393..3be9d2b579 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -52,10 +52,16 @@ async fn wait_for_snapshot(codex_home: &Path) -> Result { let snapshot_dir = codex_home.join("shell_snapshots"); let deadline = Instant::now() + Duration::from_secs(5); loop { - if let Ok(mut entries) = fs::read_dir(&snapshot_dir).await - && let Some(entry) = entries.next_entry().await? - { - return Ok(entry.path()); + if let Ok(mut entries) = fs::read_dir(&snapshot_dir).await { + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else { + continue; + }; + if extension == "sh" || extension == "ps1" { + return Ok(path); + } + } } if Instant::now() >= deadline { diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index acbc0323f4..de0b57886b 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -1,17 +1,16 @@ -#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used, clippy::unwrap_used)] +use anyhow::Result; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::skill_approval::SkillApprovalResponse; -use codex_protocol::user_input::UserInput; -use core_test_support::responses; 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_function_call_agent_response; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -20,69 +19,301 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use serde_json::json; +use std::fs; +use std::path::Path; +use std::path::PathBuf; -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn skill_approval_event_round_trip_unblocks_turn() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); +fn write_skill_with_script(home: &Path, name: &str, script_body: &str) -> Result { + let skill_dir = home.join("skills").join(name); + let scripts_dir = skill_dir.join("scripts"); + fs::create_dir_all(&scripts_dir)?; + fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {name} skill\n---\n"), + )?; + let script_path = scripts_dir.join("run.py"); + fs::write(&script_path, script_body)?; + Ok(script_path) +} - let server = start_mock_server().await; - responses::mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "approved"), - ev_completed("resp-1"), - ]), - ) - .await; +fn shell_command_arguments(command: &str) -> Result { + Ok(serde_json::to_string(&json!({ + "command": command, + "timeout_ms": 500, + }))?) +} - let builder = test_codex(); - let TestCodex { - codex, - cwd, - session_configured, - .. - } = builder - .with_config(|config| { - config.features.enable(Feature::SkillApproval); - }) - .build(&server) - .await?; +fn assistant_response(message: &str) -> String { + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", message), + ev_completed("resp-2"), + ]) +} - let turn_id = codex +fn command_for_script(script_path: &Path) -> Result { + let runner = if cfg!(windows) { "python" } else { "python3" }; + let script_path = script_path.to_string_lossy().into_owned(); + Ok(shlex::try_join([runner, script_path.as_str()])?) +} + +async fn submit_turn(test: &TestCodex, prompt: &str) -> Result<()> { + let session_model = test.session_configured.model.clone(); + test.codex .submit(Op::UserTurn { - items: vec![UserInput::Text { - text: "trigger skill approval test".into(), + items: vec![codex_protocol::user_input::UserInput::Text { + text: prompt.to_string(), text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, - model: session_configured.model.clone(), + cwd: test.cwd_path().to_path_buf(), + approval_policy: codex_protocol::protocol::AskForApproval::Never, + sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: codex_protocol::config_types::ReasoningSummary::Auto, collaboration_mode: None, personality: None, }) .await?; + Ok(()) +} - let request = wait_for_event_match(&codex, |event| match event { +async fn wait_for_turn_complete_without_skill_approval(test: &TestCodex) { + wait_for_event_match(test.codex.as_ref(), |event| match event { + EventMsg::SkillRequestApproval(request) => { + panic!("unexpected skill approval request: {request:?}"); + } + EventMsg::TurnComplete(_) => Some(()), + _ => None, + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skill_approval_event_round_trip_for_shell_command_skill_script_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let tool_call_id = "shell-skill-call"; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + write_skill_with_script(home, "demo", "print('shell skill approved')").unwrap(); + }) + .with_config(|config| { + config.features.enable(Feature::SkillApproval); + }); + let test = builder.build(&server).await?; + let script_path = test.codex_home_path().join("skills/demo/scripts/run.py"); + let command = command_for_script(&script_path)?; + let arguments = shell_command_arguments(&command)?; + let _mocks = + mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command") + .await; + + submit_turn(&test, "run the shell skill").await?; + + let request = wait_for_event_match(test.codex.as_ref(), |event| match event { EventMsg::SkillRequestApproval(request) => Some(request.clone()), _ => None, }) .await; - assert_eq!(request.item_id, turn_id); - assert_eq!(request.skill_name, "test-skill"); + assert_eq!(request.item_id, tool_call_id); + assert_eq!(request.skill_name, "demo"); - codex + test.codex .submit(Op::SkillApproval { id: request.item_id, response: SkillApprovalResponse { approved: true }, }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event(test.codex.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skill_approval_not_emitted_without_skill_script_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let tool_call_id = "non-skill-call"; + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::SkillApproval); + }); + let test = builder.build(&server).await?; + let arguments = shell_command_arguments("echo no-skill")?; + let _mocks = + mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command") + .await; + + submit_turn(&test, "run a plain command").await?; + wait_for_turn_complete_without_skill_approval(&test).await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skill_approval_decline_blocks_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let tool_call_id = "decline-call"; + let marker_name = "declined-marker.txt"; + let mut builder = test_codex() + .with_pre_build_hook(move |home| { + let marker_path = home.join(marker_name); + let marker_path = marker_path.to_string_lossy(); + let script_body = format!( + "from pathlib import Path\nPath({marker_path:?}).write_text('ran')\nprint('ran')\n" + ); + write_skill_with_script(home, "demo", &script_body).unwrap(); + }) + .with_config(|config| { + config.features.enable(Feature::SkillApproval); + }); + let test = builder.build(&server).await?; + let script_path = test.codex_home_path().join("skills/demo/scripts/run.py"); + let command = command_for_script(&script_path)?; + let arguments = shell_command_arguments(&command)?; + let mocks = + mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command") + .await; + + submit_turn(&test, "run the skill").await?; + + let request = wait_for_event_match(test.codex.as_ref(), |event| match event { + EventMsg::SkillRequestApproval(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.item_id, tool_call_id); + + test.codex + .submit(Op::SkillApproval { + id: request.item_id, + response: SkillApprovalResponse { approved: false }, + }) + .await?; + + wait_for_event(test.codex.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let marker_path = test.codex_home_path().join(marker_name); + assert!( + !marker_path.exists(), + "declined skill approval should block script execution" + ); + + let call_output = mocks + .completion + .single_request() + .function_call_output(tool_call_id); + assert_eq!( + call_output["output"].as_str(), + Some("This script is part of the skill and the user declined the skill usage"), + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skill_approval_cache_is_per_skill() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let first_call_id = "skill-a-1"; + let second_call_id = "skill-a-2"; + let third_call_id = "skill-b-1"; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + write_skill_with_script(home, "alpha", "print('alpha')").unwrap(); + write_skill_with_script(home, "beta", "print('beta')").unwrap(); + }) + .with_config(|config| { + config.features.enable(Feature::SkillApproval); + }); + let test = builder.build(&server).await?; + let alpha_command = + command_for_script(&test.codex_home_path().join("skills/alpha/scripts/run.py"))?; + let beta_command = + command_for_script(&test.codex_home_path().join("skills/beta/scripts/run.py"))?; + let first_alpha_arguments = shell_command_arguments(&alpha_command)?; + let second_alpha_arguments = shell_command_arguments(&alpha_command)?; + let beta_arguments = shell_command_arguments(&beta_command)?; + + mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(first_call_id, "shell_command", &first_alpha_arguments), + ev_completed("resp-1"), + ]), + assistant_response("alpha-1"), + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(second_call_id, "shell_command", &second_alpha_arguments), + ev_completed("resp-1"), + ]), + assistant_response("alpha-2"), + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(third_call_id, "shell_command", &beta_arguments), + ev_completed("resp-1"), + ]), + assistant_response("beta-1"), + ], + ) + .await; + + submit_turn(&test, "run alpha").await?; + let first_request = wait_for_event_match(test.codex.as_ref(), |event| match event { + EventMsg::SkillRequestApproval(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(first_request.item_id, first_call_id); + assert_eq!(first_request.skill_name, "alpha"); + test.codex + .submit(Op::SkillApproval { + id: first_request.item_id, + response: SkillApprovalResponse { approved: true }, + }) + .await?; + wait_for_event(test.codex.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + submit_turn(&test, "run alpha again").await?; + wait_for_turn_complete_without_skill_approval(&test).await; + + submit_turn(&test, "run beta").await?; + let third_request = wait_for_event_match(test.codex.as_ref(), |event| match event { + EventMsg::SkillRequestApproval(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(third_request.item_id, third_call_id); + assert_eq!(third_request.skill_name, "beta"); + test.codex + .submit(Op::SkillApproval { + id: third_request.item_id, + response: SkillApprovalResponse { approved: true }, + }) + .await?; + wait_for_event(test.codex.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; Ok(()) } diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 8f0e5834e9..113e837b30 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -33,7 +33,7 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> { let thread_id = test.session_configured.session_id; let rollout_path = test.codex.rollout_path().expect("rollout path"); - let db_path = codex_state::state_db_path(test.config.codex_home.as_path()); + let db_path = codex_state::state_db_path(test.config.sqlite_home.as_path()); for _ in 0..100 { if tokio::fs::try_exists(&db_path).await.unwrap_or(false) { @@ -161,7 +161,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { let test = builder.build(&server).await?; - let db_path = codex_state::state_db_path(test.config.codex_home.as_path()); + let db_path = codex_state::state_db_path(test.config.sqlite_home.as_path()); let rollout_path = test.config.codex_home.join(&rollout_rel_path); let default_provider = test.config.model_provider_id.clone(); @@ -220,7 +220,7 @@ async fn user_messages_persist_in_state_db() -> Result<()> { }); let test = builder.build(&server).await?; - let db_path = codex_state::state_db_path(test.config.codex_home.as_path()); + let db_path = codex_state::state_db_path(test.config.sqlite_home.as_path()); for _ in 0..100 { if tokio::fs::try_exists(&db_path).await.unwrap_or(false) { break; diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index c402371607..6cda7e4082 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -86,6 +86,10 @@ pub struct Cli { #[arg(long = "color", value_enum, default_value_t = Color::Auto)] pub color: Color, + /// Force cursor-based progress updates in exec mode. + #[arg(long = "progress-cursor", default_value_t = false)] + pub progress_cursor: bool, + /// Print events to stdout as JSONL. #[arg( long = "json", diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index f3c92b76df..6e679052e0 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -38,9 +38,12 @@ use codex_utils_elapsed::format_duration; use codex_utils_elapsed::format_elapsed; use owo_colors::OwoColorize; use owo_colors::Style; +use serde::Deserialize; use shlex::try_join; use std::collections::HashMap; +use std::io::Write; use std::path::PathBuf; +use std::time::Duration; use std::time::Instant; use crate::event_processor::CodexStatus; @@ -76,11 +79,17 @@ pub(crate) struct EventProcessorWithHumanOutput { last_total_token_usage: Option, final_message: Option, last_proposed_plan: Option, + progress_active: bool, + progress_last_len: usize, + use_ansi_cursor: bool, + progress_anchor: bool, + progress_done: bool, } impl EventProcessorWithHumanOutput { pub(crate) fn create_with_ansi( with_ansi: bool, + cursor_ansi: bool, config: &Config, last_message_path: Option, ) -> Self { @@ -103,6 +112,11 @@ impl EventProcessorWithHumanOutput { last_total_token_usage: None, final_message: None, last_proposed_plan: None, + progress_active: false, + progress_last_len: 0, + use_ansi_cursor: cursor_ansi, + progress_anchor: false, + progress_done: false, } } else { Self { @@ -121,11 +135,27 @@ impl EventProcessorWithHumanOutput { last_total_token_usage: None, final_message: None, last_proposed_plan: None, + progress_active: false, + progress_last_len: 0, + use_ansi_cursor: cursor_ansi, + progress_anchor: false, + progress_done: false, } } } } +#[derive(Debug, Deserialize)] +struct AgentJobProgressMessage { + job_id: String, + total_items: usize, + pending_items: usize, + running_items: usize, + completed_items: usize, + failed_items: usize, + eta_seconds: Option, +} + struct PatchApplyBegin { start_time: Instant, auto_approved: bool, @@ -176,6 +206,18 @@ impl EventProcessor for EventProcessorWithHumanOutput { fn process_event(&mut self, event: Event) -> CodexStatus { let Event { id: _, msg } = event; + if let EventMsg::BackgroundEvent(BackgroundEventEvent { message }) = &msg + && let Some(update) = Self::parse_agent_job_progress(message) + { + self.render_agent_job_progress(update); + return CodexStatus::Running; + } + if self.progress_active && !Self::should_interrupt_progress(&msg) { + return CodexStatus::Running; + } + if !Self::is_silent_event(&msg) { + self.finish_progress_line(); + } match msg { EventMsg::Error(ErrorEvent { message, .. }) => { let prefix = "ERROR:".style(self.red); @@ -818,6 +860,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { } fn print_final_output(&mut self) { + self.finish_progress_line(); if let Some(usage_info) = &self.last_total_token_usage { eprintln!( "{}\n{}", @@ -841,6 +884,207 @@ impl EventProcessor for EventProcessorWithHumanOutput { } } +impl EventProcessorWithHumanOutput { + fn parse_agent_job_progress(message: &str) -> Option { + let payload = message.strip_prefix("agent_job_progress:")?; + serde_json::from_str::(payload).ok() + } + + fn is_silent_event(msg: &EventMsg) -> bool { + matches!( + msg, + EventMsg::ThreadNameUpdated(_) + | EventMsg::TokenCount(_) + | EventMsg::TurnStarted(_) + | EventMsg::ExecApprovalRequest(_) + | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::TerminalInteraction(_) + | EventMsg::ExecCommandOutputDelta(_) + | EventMsg::GetHistoryEntryResponse(_) + | EventMsg::McpListToolsResponse(_) + | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) + | EventMsg::ListRemoteSkillsResponse(_) + | EventMsg::RemoteSkillDownloaded(_) + | EventMsg::RawResponseItem(_) + | EventMsg::UserMessage(_) + | EventMsg::EnteredReviewMode(_) + | EventMsg::ExitedReviewMode(_) + | EventMsg::AgentMessageDelta(_) + | EventMsg::AgentReasoningDelta(_) + | EventMsg::AgentReasoningRawContentDelta(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::AgentMessageContentDelta(_) + | EventMsg::PlanDelta(_) + | EventMsg::ReasoningContentDelta(_) + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::SkillsUpdateAvailable + | EventMsg::UndoCompleted(_) + | EventMsg::UndoStarted(_) + | EventMsg::ThreadRolledBack(_) + | EventMsg::RequestUserInput(_) + | EventMsg::DynamicToolCallRequest(_) + ) + } + + fn should_interrupt_progress(msg: &EventMsg) -> bool { + matches!( + msg, + EventMsg::Error(_) + | EventMsg::Warning(_) + | EventMsg::DeprecationNotice(_) + | EventMsg::StreamError(_) + | EventMsg::TurnComplete(_) + | EventMsg::ShutdownComplete + ) + } + + fn finish_progress_line(&mut self) { + if self.progress_active { + self.progress_active = false; + self.progress_last_len = 0; + self.progress_done = false; + if self.use_ansi_cursor { + if self.progress_anchor { + eprintln!("\u{1b}[1A\u{1b}[1G\u{1b}[2K"); + } else { + eprintln!("\u{1b}[1G\u{1b}[2K"); + } + } else { + eprintln!(); + } + self.progress_anchor = false; + } + } + + fn render_agent_job_progress(&mut self, update: AgentJobProgressMessage) { + let total = update.total_items.max(1); + let processed = update.completed_items + update.failed_items; + let percent = (processed as f64 / total as f64 * 100.0).round() as i64; + let job_label = update.job_id.chars().take(8).collect::(); + let eta = update + .eta_seconds + .map(|secs| format_duration(Duration::from_secs(secs))) + .unwrap_or_else(|| "--".to_string()); + let columns = std::env::var("COLUMNS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0); + let line = format_agent_job_progress_line( + columns, + job_label.as_str(), + AgentJobProgressStats { + processed, + total, + percent, + failed: update.failed_items, + running: update.running_items, + pending: update.pending_items, + }, + eta.as_str(), + ); + let done = processed >= update.total_items; + if !self.use_ansi_cursor { + eprintln!("{line}"); + if done { + self.progress_active = false; + self.progress_last_len = 0; + } + return; + } + if done && self.progress_done { + return; + } + if !self.progress_active { + eprintln!(); + self.progress_anchor = true; + self.progress_done = false; + } + let mut output = String::new(); + if self.progress_anchor { + output.push_str("\u{1b}[1A\u{1b}[1G\u{1b}[2K"); + } else { + output.push_str("\u{1b}[1G\u{1b}[2K"); + } + output.push_str(&line); + if done { + output.push('\n'); + eprint!("{output}"); + self.progress_active = false; + self.progress_last_len = 0; + self.progress_anchor = false; + self.progress_done = true; + return; + } + eprint!("{output}"); + let _ = std::io::stderr().flush(); + self.progress_active = true; + self.progress_last_len = line.len(); + } +} + +struct AgentJobProgressStats { + processed: usize, + total: usize, + percent: i64, + failed: usize, + running: usize, + pending: usize, +} + +fn format_agent_job_progress_line( + columns: Option, + job_label: &str, + stats: AgentJobProgressStats, + eta: &str, +) -> String { + let rest = format!( + "{processed}/{total} {percent}% f{failed} r{running} p{pending} eta {eta}", + processed = stats.processed, + total = stats.total, + percent = stats.percent, + failed = stats.failed, + running = stats.running, + pending = stats.pending + ); + let prefix = format!("job {job_label}"); + let base_len = prefix.len() + rest.len() + 4; + let mut bar_width = columns + .and_then(|columns| columns.checked_sub(base_len)) + .filter(|available| *available > 0) + .unwrap_or(20usize); + let with_bar = |width: usize| { + let filled = ((stats.processed as f64 / stats.total as f64) * width as f64) + .round() + .clamp(0.0, width as f64) as usize; + let mut bar = "#".repeat(filled); + bar.push_str(&"-".repeat(width - filled)); + format!("{prefix} [{bar}] {rest}") + }; + let mut line = with_bar(bar_width); + if let Some(columns) = columns + && line.len() > columns + { + let min_line = format!("{prefix} {rest}"); + if min_line.len() > columns { + let mut truncated = min_line; + if columns > 2 && truncated.len() > columns { + truncated.truncate(columns - 2); + truncated.push_str(".."); + } + return truncated; + } + let available = columns.saturating_sub(base_len); + if available == 0 { + return min_line; + } + bar_width = available.min(bar_width).max(1); + line = with_bar(bar_width); + } + line +} + fn escape_command(command: &[String]) -> String { try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 89180d89e6..cd577ca647 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -13,6 +13,7 @@ pub mod exec_events; pub use cli::Cli; pub use cli::Command; pub use cli::ReviewArgs; +use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader; use codex_core::AuthManager; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; @@ -41,6 +42,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_oss::ensure_oss_provider_ready; @@ -86,9 +88,10 @@ struct ThreadEventEnvelope { thread_id: codex_protocol::ThreadId, thread: Arc, event: Event, + suppress_output: bool, } -pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { +pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { if let Err(err) = set_default_originator("codex_exec".to_string()) { tracing::warn!(?err, "Failed to set codex exec originator override {err:?}"); } @@ -113,9 +116,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any prompt, output_schema: output_schema_path, config_overrides, + progress_cursor, } = cli; - let (stdout_with_ansi, stderr_with_ansi) = match color { + let (_stdout_with_ansi, stderr_with_ansi) = match color { cli::Color::Always => (true, true), cli::Color::Never => (false, false), cli::Color::Auto => ( @@ -123,6 +127,24 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any supports_color::on_cached(Stream::Stderr).is_some(), ), }; + let cursor_ansi = if progress_cursor { + true + } else { + match color { + cli::Color::Never => false, + cli::Color::Always => true, + cli::Color::Auto => { + if stderr_with_ansi || std::io::stderr().is_terminal() { + true + } else { + match std::env::var("TERM") { + Ok(term) => !term.is_empty() && term != "dumb", + Err(_) => false, + } + } + } + } + }; // Build fmt layer (existing logging) to compose with OTEL layer. let default_level = "error"; @@ -250,7 +272,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any sandbox_mode, cwd: resolved_cwd, model_provider: model_provider.clone(), - codex_linux_sandbox_exe, + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), js_repl_node_path: None, js_repl_node_module_dirs: None, zsh_path: None, @@ -318,7 +341,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let mut event_processor: Box = match json_mode { true => Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())), _ => Box::new(EventProcessorWithHumanOutput::create_with_ansi( - stdout_with_ansi, + stderr_with_ansi, + cursor_ansi, &config, last_message_file.clone(), )), @@ -466,7 +490,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let attached_threads = Arc::new(Mutex::new(HashSet::from([primary_thread_id]))); - spawn_thread_listener(primary_thread_id, thread.clone(), tx.clone()); + spawn_thread_listener(primary_thread_id, thread.clone(), tx.clone(), false); { let thread = thread.clone(); @@ -494,7 +518,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any match thread_manager.get_thread(thread_id).await { Ok(thread) => { attached_threads.lock().await.insert(thread_id); - spawn_thread_listener(thread_id, thread, tx.clone()); + let suppress_output = + is_agent_job_subagent(&thread.config_snapshot().await); + spawn_thread_listener( + thread_id, + thread, + tx.clone(), + suppress_output, + ); } Err(err) => { warn!("failed to attach listener for thread {thread_id}: {err}") @@ -549,7 +580,11 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any thread_id, thread, event, + suppress_output, } = envelope; + if suppress_output && should_suppress_agent_job_event(&event.msg) { + continue; + } if matches!(event.msg, EventMsg::Error(_)) { error_seen = true; } @@ -613,6 +648,7 @@ fn spawn_thread_listener( thread_id: codex_protocol::ThreadId, thread: Arc, tx: tokio::sync::mpsc::UnboundedSender, + suppress_output: bool, ) { tokio::spawn(async move { loop { @@ -625,6 +661,7 @@ fn spawn_thread_listener( thread_id, thread: Arc::clone(&thread), event, + suppress_output, }) { error!("Error sending event: {err:?}"); break; @@ -645,6 +682,29 @@ fn spawn_thread_listener( }); } +fn is_agent_job_subagent(config: &codex_core::ThreadConfigSnapshot) -> bool { + match &config.session_source { + SessionSource::SubAgent(SubAgentSource::Other(source)) => source.starts_with("agent_job:"), + _ => false, + } +} + +fn should_suppress_agent_job_event(msg: &EventMsg) -> bool { + !matches!( + msg, + EventMsg::ExecApprovalRequest(_) + | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::RequestUserInput(_) + | EventMsg::DynamicToolCallRequest(_) + | EventMsg::ElicitationRequest(_) + | EventMsg::Error(_) + | EventMsg::Warning(_) + | EventMsg::DeprecationNotice(_) + | EventMsg::StreamError(_) + | EventMsg::ShutdownComplete + ) +} + async fn resolve_resume_path( config: &Config, args: &crate::cli::ResumeArgs, diff --git a/codex-rs/exec/src/main.rs b/codex-rs/exec/src/main.rs index 9c6cf52f9f..e4cbd25ad2 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -10,6 +10,7 @@ //! This allows us to ship a completely separate set of functionality as part //! of the `codex-exec` binary. use clap::Parser; +use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_exec::Cli; use codex_exec::run_main; @@ -25,7 +26,7 @@ struct TopCli { } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let top_cli = TopCli::parse(); // Merge root-level overrides into inner CLI struct so downstream logic remains unchanged. let mut inner = top_cli.inner; @@ -34,7 +35,7 @@ fn main() -> anyhow::Result<()> { .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); - run_main(inner, codex_linux_sandbox_exe).await?; + run_main(inner, arg0_paths).await?; Ok(()) }) } diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 362fcaf35f..b9c0d6c8b6 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -73,6 +73,7 @@ async fn run_cmd_result_with_writable_roots( let sandbox_cwd = cwd.clone(); let params = ExecParams { command: cmd.iter().copied().map(str::to_owned).collect(), + original_command: cmd.iter().copied().map(str::to_owned).collect(), cwd, expiration: timeout_ms.into(), env: create_env_from_core_vars(), @@ -315,6 +316,7 @@ async fn assert_network_blocked(cmd: &[&str]) { let sandbox_cwd = cwd.clone(); let params = ExecParams { command: cmd.iter().copied().map(str::to_owned).collect(), + original_command: cmd.iter().copied().map(str::to_owned).collect(), cwd, // Give the tool a generous 2-second timeout so even slow DNS timeouts // do not stall the suite. diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index f0b3824230..53b4ad3601 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -431,7 +431,7 @@ fn build_authorize_url( ("redirect_uri".to_string(), redirect_uri.to_string()), ( "scope".to_string(), - "openid profile email offline_access api.model.audio.request".to_string(), + "openid profile email offline_access".to_string(), ), ( "code_challenge".to_string(), diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 3125221d1a..f3f66b5559 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -1,5 +1,6 @@ //! Configuration object accepted by the `codex` MCP tool-call. +use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_protocol::ThreadId; @@ -153,7 +154,7 @@ impl CodexToolCallParam { /// effective Config object generated from the supplied parameters. pub async fn into_config( self, - codex_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, ) -> std::io::Result<(String, Config)> { let Self { prompt, @@ -175,7 +176,8 @@ impl CodexToolCallParam { cwd: cwd.map(PathBuf::from), approval_policy: approval_policy.map(Into::into), sandbox_mode: sandbox.map(Into::into), - codex_linux_sandbox_exe, + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, developer_instructions, compact_prompt, diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index fd4475a61d..554daf9a8c 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -3,8 +3,8 @@ use std::io::ErrorKind; use std::io::Result as IoResult; -use std::path::PathBuf; +use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; use codex_utils_cli::CliConfigOverrides; @@ -49,7 +49,7 @@ const CHANNEL_CAPACITY: usize = 128; type IncomingMessage = JsonRpcMessage; pub async fn run_main( - codex_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, ) -> IoResult<()> { // Install a simple subscriber so `tracing` output is visible. Users can @@ -105,7 +105,7 @@ pub async fn run_main( let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); let mut processor = MessageProcessor::new( outgoing_message_sender, - codex_linux_sandbox_exe, + arg0_paths, std::sync::Arc::new(config), ); async move { diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index c6ffd49b40..ce61fd04d1 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -1,10 +1,11 @@ +use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_mcp_server::run_main; use codex_utils_cli::CliConfigOverrides; fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { - run_main(codex_linux_sandbox_exe, CliConfigOverrides::default()).await?; + arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { + run_main(arg0_paths, CliConfigOverrides::default()).await?; Ok(()) }) } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 95d48aa822..d6cf0d23e1 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use std::path::PathBuf; +use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; use codex_core::ThreadManager; use codex_core::config::Config; @@ -38,7 +38,7 @@ use crate::outgoing_message::OutgoingMessageSender; pub(crate) struct MessageProcessor { outgoing: Arc, initialized: bool, - codex_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, thread_manager: Arc, running_requests_id_to_codex_uuid: Arc>>, } @@ -48,7 +48,7 @@ impl MessageProcessor { /// `Sender` so handlers can enqueue messages to be written to stdout. pub(crate) fn new( outgoing: OutgoingMessageSender, - codex_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); @@ -66,7 +66,7 @@ impl MessageProcessor { Self { outgoing, initialized: false, - codex_linux_sandbox_exe, + arg0_paths, thread_manager, running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())), } @@ -352,10 +352,7 @@ impl MessageProcessor { let arguments = arguments.map(serde_json::Value::Object); let (initial_prompt, config): (String, Config) = match arguments { Some(json_val) => match serde_json::from_value::(json_val) { - Ok(tool_cfg) => match tool_cfg - .into_config(self.codex_linux_sandbox_exe.clone()) - .await - { + Ok(tool_cfg) => match tool_cfg.into_config(self.arg0_paths.clone()).await { Ok(cfg) => cfg, Err(e) => { let result = CallToolResult { diff --git a/codex-rs/state/migrations/0014_agent_jobs.sql b/codex-rs/state/migrations/0014_agent_jobs.sql new file mode 100644 index 0000000000..d8968c405e --- /dev/null +++ b/codex-rs/state/migrations/0014_agent_jobs.sql @@ -0,0 +1,38 @@ +CREATE TABLE agent_jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL, + instruction TEXT NOT NULL, + output_schema_json TEXT, + input_headers_json TEXT NOT NULL, + input_csv_path TEXT NOT NULL, + output_csv_path TEXT NOT NULL, + auto_export INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + last_error TEXT +); + +CREATE TABLE agent_job_items ( + job_id TEXT NOT NULL, + item_id TEXT NOT NULL, + row_index INTEGER NOT NULL, + source_id TEXT, + row_json TEXT NOT NULL, + status TEXT NOT NULL, + assigned_thread_id TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0, + result_json TEXT, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + reported_at INTEGER, + PRIMARY KEY (job_id, item_id), + FOREIGN KEY(job_id) REFERENCES agent_jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_agent_jobs_status ON agent_jobs(status, updated_at DESC); +CREATE INDEX idx_agent_job_items_status ON agent_job_items(job_id, status, row_index ASC); diff --git a/codex-rs/state/migrations/0015_agent_jobs_max_runtime_seconds.sql b/codex-rs/state/migrations/0015_agent_jobs_max_runtime_seconds.sql new file mode 100644 index 0000000000..ab1006d452 --- /dev/null +++ b/codex-rs/state/migrations/0015_agent_jobs_max_runtime_seconds.sql @@ -0,0 +1,2 @@ +ALTER TABLE agent_jobs +ADD COLUMN max_runtime_seconds INTEGER; diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 062db796af..59607f3d9b 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -22,6 +22,13 @@ pub use runtime::StateRuntime; /// /// Most consumers should prefer [`StateRuntime`]. pub use extract::apply_rollout_item; +pub use model::AgentJob; +pub use model::AgentJobCreateParams; +pub use model::AgentJobItem; +pub use model::AgentJobItemCreateParams; +pub use model::AgentJobItemStatus; +pub use model::AgentJobProgress; +pub use model::AgentJobStatus; pub use model::Anchor; pub use model::BackfillState; pub use model::BackfillStats; @@ -38,6 +45,9 @@ pub use model::ThreadsPage; pub use runtime::state_db_filename; pub use runtime::state_db_path; +/// Environment variable for overriding the SQLite state database home directory. +pub const SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; + pub const STATE_DB_FILENAME: &str = "state"; pub const STATE_DB_VERSION: u32 = 5; diff --git a/codex-rs/state/src/model/agent_job.rs b/codex-rs/state/src/model/agent_job.rs new file mode 100644 index 0000000000..36f9ff12da --- /dev/null +++ b/codex-rs/state/src/model/agent_job.rs @@ -0,0 +1,256 @@ +use anyhow::Result; +use chrono::DateTime; +use chrono::Utc; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentJobStatus { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +impl AgentJobStatus { + pub const fn as_str(self) -> &'static str { + match self { + AgentJobStatus::Pending => "pending", + AgentJobStatus::Running => "running", + AgentJobStatus::Completed => "completed", + AgentJobStatus::Failed => "failed", + AgentJobStatus::Cancelled => "cancelled", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "running" => Ok(Self::Running), + "completed" => Ok(Self::Completed), + "failed" => Ok(Self::Failed), + "cancelled" => Ok(Self::Cancelled), + _ => Err(anyhow::anyhow!("invalid agent job status: {value}")), + } + } + + pub fn is_final(self) -> bool { + matches!( + self, + AgentJobStatus::Completed | AgentJobStatus::Failed | AgentJobStatus::Cancelled + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentJobItemStatus { + Pending, + Running, + Completed, + Failed, +} + +impl AgentJobItemStatus { + pub const fn as_str(self) -> &'static str { + match self { + AgentJobItemStatus::Pending => "pending", + AgentJobItemStatus::Running => "running", + AgentJobItemStatus::Completed => "completed", + AgentJobItemStatus::Failed => "failed", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "running" => Ok(Self::Running), + "completed" => Ok(Self::Completed), + "failed" => Ok(Self::Failed), + _ => Err(anyhow::anyhow!("invalid agent job item status: {value}")), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgentJob { + pub id: String, + pub name: String, + pub status: AgentJobStatus, + pub instruction: String, + pub auto_export: bool, + pub max_runtime_seconds: Option, + // TODO(jif-oai): Convert to JSON Schema and enforce structured outputs. + pub output_schema_json: Option, + pub input_headers: Vec, + pub input_csv_path: String, + pub output_csv_path: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, + pub last_error: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgentJobItem { + pub job_id: String, + pub item_id: String, + pub row_index: i64, + pub source_id: Option, + pub row_json: Value, + pub status: AgentJobItemStatus, + pub assigned_thread_id: Option, + pub attempt_count: i64, + pub result_json: Option, + pub last_error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub completed_at: Option>, + pub reported_at: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentJobProgress { + pub total_items: usize, + pub pending_items: usize, + pub running_items: usize, + pub completed_items: usize, + pub failed_items: usize, +} + +#[derive(Debug, Clone)] +pub struct AgentJobCreateParams { + pub id: String, + pub name: String, + pub instruction: String, + pub auto_export: bool, + pub max_runtime_seconds: Option, + pub output_schema_json: Option, + pub input_headers: Vec, + pub input_csv_path: String, + pub output_csv_path: String, +} + +#[derive(Debug, Clone)] +pub struct AgentJobItemCreateParams { + pub item_id: String, + pub row_index: i64, + pub source_id: Option, + pub row_json: Value, +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct AgentJobRow { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) status: String, + pub(crate) instruction: String, + pub(crate) auto_export: i64, + pub(crate) max_runtime_seconds: Option, + pub(crate) output_schema_json: Option, + pub(crate) input_headers_json: String, + pub(crate) input_csv_path: String, + pub(crate) output_csv_path: String, + pub(crate) created_at: i64, + pub(crate) updated_at: i64, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, + pub(crate) last_error: Option, +} + +impl TryFrom for AgentJob { + type Error = anyhow::Error; + + fn try_from(value: AgentJobRow) -> Result { + let output_schema_json = value + .output_schema_json + .as_deref() + .map(serde_json::from_str) + .transpose()?; + let input_headers = serde_json::from_str(value.input_headers_json.as_str())?; + let max_runtime_seconds = value + .max_runtime_seconds + .map(u64::try_from) + .transpose() + .map_err(|_| anyhow::anyhow!("invalid max_runtime_seconds value"))?; + Ok(Self { + id: value.id, + name: value.name, + status: AgentJobStatus::parse(value.status.as_str())?, + instruction: value.instruction, + auto_export: value.auto_export != 0, + max_runtime_seconds, + output_schema_json, + input_headers, + input_csv_path: value.input_csv_path, + output_csv_path: value.output_csv_path, + created_at: epoch_seconds_to_datetime(value.created_at)?, + updated_at: epoch_seconds_to_datetime(value.updated_at)?, + started_at: value + .started_at + .map(epoch_seconds_to_datetime) + .transpose()?, + completed_at: value + .completed_at + .map(epoch_seconds_to_datetime) + .transpose()?, + last_error: value.last_error, + }) + } +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct AgentJobItemRow { + pub(crate) job_id: String, + pub(crate) item_id: String, + pub(crate) row_index: i64, + pub(crate) source_id: Option, + pub(crate) row_json: String, + pub(crate) status: String, + pub(crate) assigned_thread_id: Option, + pub(crate) attempt_count: i64, + pub(crate) result_json: Option, + pub(crate) last_error: Option, + pub(crate) created_at: i64, + pub(crate) updated_at: i64, + pub(crate) completed_at: Option, + pub(crate) reported_at: Option, +} + +impl TryFrom for AgentJobItem { + type Error = anyhow::Error; + + fn try_from(value: AgentJobItemRow) -> Result { + Ok(Self { + job_id: value.job_id, + item_id: value.item_id, + row_index: value.row_index, + source_id: value.source_id, + row_json: serde_json::from_str(value.row_json.as_str())?, + status: AgentJobItemStatus::parse(value.status.as_str())?, + assigned_thread_id: value.assigned_thread_id, + attempt_count: value.attempt_count, + result_json: value + .result_json + .as_deref() + .map(serde_json::from_str) + .transpose()?, + last_error: value.last_error, + created_at: epoch_seconds_to_datetime(value.created_at)?, + updated_at: epoch_seconds_to_datetime(value.updated_at)?, + completed_at: value + .completed_at + .map(epoch_seconds_to_datetime) + .transpose()?, + reported_at: value + .reported_at + .map(epoch_seconds_to_datetime) + .transpose()?, + }) + } +} + +fn epoch_seconds_to_datetime(secs: i64) -> Result> { + DateTime::::from_timestamp(secs, 0) + .ok_or_else(|| anyhow::anyhow!("invalid unix timestamp: {secs}")) +} diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs index 2a0009d647..816c036f8b 100644 --- a/codex-rs/state/src/model/mod.rs +++ b/codex-rs/state/src/model/mod.rs @@ -1,8 +1,16 @@ +mod agent_job; mod backfill_state; mod log; mod memories; mod thread_metadata; +pub use agent_job::AgentJob; +pub use agent_job::AgentJobCreateParams; +pub use agent_job::AgentJobItem; +pub use agent_job::AgentJobItemCreateParams; +pub use agent_job::AgentJobItemStatus; +pub use agent_job::AgentJobProgress; +pub use agent_job::AgentJobStatus; pub use backfill_state::BackfillState; pub use backfill_state::BackfillStatus; pub use log::LogEntry; @@ -21,6 +29,8 @@ pub use thread_metadata::ThreadMetadata; pub use thread_metadata::ThreadMetadataBuilder; pub use thread_metadata::ThreadsPage; +pub(crate) use agent_job::AgentJobItemRow; +pub(crate) use agent_job::AgentJobRow; pub(crate) use memories::Stage1OutputRow; pub(crate) use thread_metadata::ThreadRow; pub(crate) use thread_metadata::anchor_from_item; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index f027cac6e8..29d85cdd52 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -1,3 +1,10 @@ +use crate::AgentJob; +use crate::AgentJobCreateParams; +use crate::AgentJobItem; +use crate::AgentJobItemCreateParams; +use crate::AgentJobItemStatus; +use crate::AgentJobProgress; +use crate::AgentJobStatus; use crate::DB_ERROR_METRIC; use crate::LogEntry; use crate::LogQuery; @@ -11,6 +18,8 @@ use crate::ThreadMetadataBuilder; use crate::ThreadsPage; use crate::apply_rollout_item; use crate::migrations::MIGRATOR; +use crate::model::AgentJobItemRow; +use crate::model::AgentJobRow; use crate::model::ThreadRow; use crate::model::anchor_from_item; use crate::model::datetime_to_epoch_seconds; @@ -901,6 +910,564 @@ ON CONFLICT(thread_id, position) DO NOTHING Ok(result.rows_affected()) } + pub async fn create_agent_job( + &self, + params: &AgentJobCreateParams, + items: &[AgentJobItemCreateParams], + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let input_headers_json = serde_json::to_string(¶ms.input_headers)?; + let output_schema_json = params + .output_schema_json + .as_ref() + .map(serde_json::to_string) + .transpose()?; + let max_runtime_seconds = params + .max_runtime_seconds + .map(i64::try_from) + .transpose() + .map_err(|_| anyhow::anyhow!("invalid max_runtime_seconds value"))?; + let mut tx = self.pool.begin().await?; + sqlx::query( + r#" +INSERT INTO agent_jobs ( + id, + name, + status, + instruction, + auto_export, + max_runtime_seconds, + output_schema_json, + input_headers_json, + input_csv_path, + output_csv_path, + created_at, + updated_at, + started_at, + completed_at, + last_error +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL) + "#, + ) + .bind(params.id.as_str()) + .bind(params.name.as_str()) + .bind(AgentJobStatus::Pending.as_str()) + .bind(params.instruction.as_str()) + .bind(i64::from(params.auto_export)) + .bind(max_runtime_seconds) + .bind(output_schema_json) + .bind(input_headers_json) + .bind(params.input_csv_path.as_str()) + .bind(params.output_csv_path.as_str()) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await?; + + for item in items { + let row_json = serde_json::to_string(&item.row_json)?; + sqlx::query( + r#" +INSERT INTO agent_job_items ( + job_id, + item_id, + row_index, + source_id, + row_json, + status, + assigned_thread_id, + attempt_count, + result_json, + last_error, + created_at, + updated_at, + completed_at, + reported_at +) VALUES (?, ?, ?, ?, ?, ?, NULL, 0, NULL, NULL, ?, ?, NULL, NULL) + "#, + ) + .bind(params.id.as_str()) + .bind(item.item_id.as_str()) + .bind(item.row_index) + .bind(item.source_id.as_deref()) + .bind(row_json) + .bind(AgentJobItemStatus::Pending.as_str()) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + let job_id = params.id.as_str(); + self.get_agent_job(job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("failed to load created agent job {job_id}")) + } + + pub async fn get_agent_job(&self, job_id: &str) -> anyhow::Result> { + let row = sqlx::query_as::<_, AgentJobRow>( + r#" +SELECT + id, + name, + status, + instruction, + auto_export, + max_runtime_seconds, + output_schema_json, + input_headers_json, + input_csv_path, + output_csv_path, + created_at, + updated_at, + started_at, + completed_at, + last_error +FROM agent_jobs +WHERE id = ? + "#, + ) + .bind(job_id) + .fetch_optional(self.pool.as_ref()) + .await?; + row.map(AgentJob::try_from).transpose() + } + + pub async fn list_agent_job_items( + &self, + job_id: &str, + status: Option, + limit: Option, + ) -> anyhow::Result> { + let mut builder = QueryBuilder::::new( + r#" +SELECT + job_id, + item_id, + row_index, + source_id, + row_json, + status, + assigned_thread_id, + attempt_count, + result_json, + last_error, + created_at, + updated_at, + completed_at, + reported_at +FROM agent_job_items +WHERE job_id = + "#, + ); + builder.push_bind(job_id); + if let Some(status) = status { + builder.push(" AND status = "); + builder.push_bind(status.as_str()); + } + builder.push(" ORDER BY row_index ASC"); + if let Some(limit) = limit { + builder.push(" LIMIT "); + builder.push_bind(limit as i64); + } + let rows = builder + .build_query_as::() + .fetch_all(self.pool.as_ref()) + .await?; + rows.into_iter().map(AgentJobItem::try_from).collect() + } + + pub async fn get_agent_job_item( + &self, + job_id: &str, + item_id: &str, + ) -> anyhow::Result> { + let row = sqlx::query_as::<_, AgentJobItemRow>( + r#" +SELECT + job_id, + item_id, + row_index, + source_id, + row_json, + status, + assigned_thread_id, + attempt_count, + result_json, + last_error, + created_at, + updated_at, + completed_at, + reported_at +FROM agent_job_items +WHERE job_id = ? AND item_id = ? + "#, + ) + .bind(job_id) + .bind(item_id) + .fetch_optional(self.pool.as_ref()) + .await?; + row.map(AgentJobItem::try_from).transpose() + } + + pub async fn mark_agent_job_running(&self, job_id: &str) -> anyhow::Result<()> { + let now = Utc::now().timestamp(); + sqlx::query( + r#" +UPDATE agent_jobs +SET + status = ?, + updated_at = ?, + started_at = COALESCE(started_at, ?), + completed_at = NULL, + last_error = NULL +WHERE id = ? + "#, + ) + .bind(AgentJobStatus::Running.as_str()) + .bind(now) + .bind(now) + .bind(job_id) + .execute(self.pool.as_ref()) + .await?; + Ok(()) + } + + pub async fn mark_agent_job_completed(&self, job_id: &str) -> anyhow::Result<()> { + let now = Utc::now().timestamp(); + sqlx::query( + r#" +UPDATE agent_jobs +SET status = ?, updated_at = ?, completed_at = ?, last_error = NULL +WHERE id = ? + "#, + ) + .bind(AgentJobStatus::Completed.as_str()) + .bind(now) + .bind(now) + .bind(job_id) + .execute(self.pool.as_ref()) + .await?; + Ok(()) + } + + pub async fn mark_agent_job_failed( + &self, + job_id: &str, + error_message: &str, + ) -> anyhow::Result<()> { + let now = Utc::now().timestamp(); + sqlx::query( + r#" +UPDATE agent_jobs +SET status = ?, updated_at = ?, completed_at = ?, last_error = ? +WHERE id = ? + "#, + ) + .bind(AgentJobStatus::Failed.as_str()) + .bind(now) + .bind(now) + .bind(error_message) + .bind(job_id) + .execute(self.pool.as_ref()) + .await?; + Ok(()) + } + + pub async fn mark_agent_job_cancelled( + &self, + job_id: &str, + reason: &str, + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let result = sqlx::query( + r#" +UPDATE agent_jobs +SET status = ?, updated_at = ?, completed_at = ?, last_error = ? +WHERE id = ? AND status IN (?, ?) + "#, + ) + .bind(AgentJobStatus::Cancelled.as_str()) + .bind(now) + .bind(now) + .bind(reason) + .bind(job_id) + .bind(AgentJobStatus::Pending.as_str()) + .bind(AgentJobStatus::Running.as_str()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn is_agent_job_cancelled(&self, job_id: &str) -> anyhow::Result { + let row = sqlx::query( + r#" +SELECT status +FROM agent_jobs +WHERE id = ? + "#, + ) + .bind(job_id) + .fetch_optional(self.pool.as_ref()) + .await?; + let Some(row) = row else { + return Ok(false); + }; + let status: String = row.try_get("status")?; + Ok(AgentJobStatus::parse(status.as_str())? == AgentJobStatus::Cancelled) + } + + pub async fn mark_agent_job_item_running( + &self, + job_id: &str, + item_id: &str, + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let result = sqlx::query( + r#" +UPDATE agent_job_items +SET + status = ?, + assigned_thread_id = NULL, + attempt_count = attempt_count + 1, + updated_at = ?, + last_error = NULL +WHERE job_id = ? AND item_id = ? AND status = ? + "#, + ) + .bind(AgentJobItemStatus::Running.as_str()) + .bind(now) + .bind(job_id) + .bind(item_id) + .bind(AgentJobItemStatus::Pending.as_str()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn mark_agent_job_item_running_with_thread( + &self, + job_id: &str, + item_id: &str, + thread_id: &str, + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let result = sqlx::query( + r#" +UPDATE agent_job_items +SET + status = ?, + assigned_thread_id = ?, + attempt_count = attempt_count + 1, + updated_at = ?, + last_error = NULL +WHERE job_id = ? AND item_id = ? AND status = ? + "#, + ) + .bind(AgentJobItemStatus::Running.as_str()) + .bind(thread_id) + .bind(now) + .bind(job_id) + .bind(item_id) + .bind(AgentJobItemStatus::Pending.as_str()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn mark_agent_job_item_pending( + &self, + job_id: &str, + item_id: &str, + error_message: Option<&str>, + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let result = sqlx::query( + r#" +UPDATE agent_job_items +SET + status = ?, + assigned_thread_id = NULL, + updated_at = ?, + last_error = ? +WHERE job_id = ? AND item_id = ? AND status = ? + "#, + ) + .bind(AgentJobItemStatus::Pending.as_str()) + .bind(now) + .bind(error_message) + .bind(job_id) + .bind(item_id) + .bind(AgentJobItemStatus::Running.as_str()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn set_agent_job_item_thread( + &self, + job_id: &str, + item_id: &str, + thread_id: &str, + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let result = sqlx::query( + r#" +UPDATE agent_job_items +SET assigned_thread_id = ?, updated_at = ? +WHERE job_id = ? AND item_id = ? AND status = ? + "#, + ) + .bind(thread_id) + .bind(now) + .bind(job_id) + .bind(item_id) + .bind(AgentJobItemStatus::Running.as_str()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn report_agent_job_item_result( + &self, + job_id: &str, + item_id: &str, + reporting_thread_id: &str, + result_json: &Value, + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let serialized = serde_json::to_string(result_json)?; + let result = sqlx::query( + r#" +UPDATE agent_job_items +SET + result_json = ?, + reported_at = ?, + updated_at = ?, + last_error = NULL +WHERE + job_id = ? + AND item_id = ? + AND status = ? + AND assigned_thread_id = ? + "#, + ) + .bind(serialized) + .bind(now) + .bind(now) + .bind(job_id) + .bind(item_id) + .bind(AgentJobItemStatus::Running.as_str()) + .bind(reporting_thread_id) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn mark_agent_job_item_completed( + &self, + job_id: &str, + item_id: &str, + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let result = sqlx::query( + r#" +UPDATE agent_job_items +SET + status = ?, + completed_at = ?, + updated_at = ?, + assigned_thread_id = NULL +WHERE + job_id = ? + AND item_id = ? + AND status = ? + AND result_json IS NOT NULL + "#, + ) + .bind(AgentJobItemStatus::Completed.as_str()) + .bind(now) + .bind(now) + .bind(job_id) + .bind(item_id) + .bind(AgentJobItemStatus::Running.as_str()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn mark_agent_job_item_failed( + &self, + job_id: &str, + item_id: &str, + error_message: &str, + ) -> anyhow::Result { + let now = Utc::now().timestamp(); + let result = sqlx::query( + r#" +UPDATE agent_job_items +SET + status = ?, + completed_at = ?, + updated_at = ?, + last_error = ?, + assigned_thread_id = NULL +WHERE + job_id = ? + AND item_id = ? + AND status = ? + "#, + ) + .bind(AgentJobItemStatus::Failed.as_str()) + .bind(now) + .bind(now) + .bind(error_message) + .bind(job_id) + .bind(item_id) + .bind(AgentJobItemStatus::Running.as_str()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn get_agent_job_progress(&self, job_id: &str) -> anyhow::Result { + let row = sqlx::query( + r#" +SELECT + COUNT(*) AS total_items, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS pending_items, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS running_items, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS completed_items, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS failed_items +FROM agent_job_items +WHERE job_id = ? + "#, + ) + .bind(AgentJobItemStatus::Pending.as_str()) + .bind(AgentJobItemStatus::Running.as_str()) + .bind(AgentJobItemStatus::Completed.as_str()) + .bind(AgentJobItemStatus::Failed.as_str()) + .bind(job_id) + .fetch_one(self.pool.as_ref()) + .await?; + + let total_items: i64 = row.try_get("total_items")?; + let pending_items: Option = row.try_get("pending_items")?; + let running_items: Option = row.try_get("running_items")?; + let completed_items: Option = row.try_get("completed_items")?; + let failed_items: Option = row.try_get("failed_items")?; + Ok(AgentJobProgress { + total_items: usize::try_from(total_items).unwrap_or_default(), + pending_items: usize::try_from(pending_items.unwrap_or_default()).unwrap_or_default(), + running_items: usize::try_from(running_items.unwrap_or_default()).unwrap_or_default(), + completed_items: usize::try_from(completed_items.unwrap_or_default()) + .unwrap_or_default(), + failed_items: usize::try_from(failed_items.unwrap_or_default()).unwrap_or_default(), + }) + } + async fn ensure_backfill_state_row(&self) -> anyhow::Result<()> { sqlx::query( r#" diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e81894471f..8d9e4b250d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -389,6 +389,7 @@ pub(crate) struct ChatComposer { collaboration_mode_indicator: Option, connectors_enabled: bool, personality_command_enabled: bool, + realtime_conversation_enabled: bool, windows_degraded_sandbox_active: bool, status_line_value: Option>, status_line_enabled: bool, @@ -494,6 +495,7 @@ impl ChatComposer { collaboration_mode_indicator: None, connectors_enabled: false, personality_command_enabled: false, + realtime_conversation_enabled: false, windows_degraded_sandbox_active: false, status_line_value: None, status_line_enabled: false, @@ -578,6 +580,10 @@ impl ChatComposer { self.personality_command_enabled = enabled; } + pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) { + self.realtime_conversation_enabled = enabled; + } + pub fn set_voice_transcription_enabled(&mut self, enabled: bool) { self.voice_state.transcription_enabled = enabled; if !enabled { @@ -1641,7 +1647,11 @@ impl ChatComposer { } => { let Some(sel) = popup.selected_match() else { self.active_popup = ActivePopup::None; - return (InputResult::None, true); + return if key_event.code == KeyCode::Enter { + self.handle_key_event_without_popup(key_event) + } else { + (InputResult::None, true) + }; }; let sel_path = sel.to_string_lossy().to_string(); @@ -1692,7 +1702,6 @@ impl ChatComposer { // Non-image: inserting file path. self.insert_selected_path(&sel_path); } - // No selection: treat Enter as closing the popup/session. self.active_popup = ActivePopup::None; (InputResult::None, true) } @@ -1912,6 +1921,10 @@ impl ChatComposer { /// - The cursor may be anywhere *inside* the token (including on the /// leading prefix). It does **not** need to be at the end of the line. /// - A token is delimited by ASCII whitespace (space, tab, newline). + /// - If the cursor is on `prefix` inside an existing token (for example the + /// second `@` in `@scope/pkg@latest`), keep treating the surrounding + /// whitespace-delimited token as the active token rather than starting a + /// new token at that nested prefix. /// - If the token under the cursor starts with `prefix`, that token is /// returned without the leading prefix. When `allow_empty` is true, a /// lone prefix character yields `Some(String::new())` to surface hints. @@ -2005,7 +2018,15 @@ impl ChatComposer { return left_prefixed; } if after_cursor.starts_with(prefix) { - return right_prefixed.or(left_prefixed); + let prefix_starts_token = before_cursor + .chars() + .next_back() + .is_none_or(char::is_whitespace); + return if prefix_starts_token { + right_prefixed.or(left_prefixed) + } else { + left_prefixed + }; } left_prefixed.or(right_prefixed) } @@ -2245,6 +2266,7 @@ impl ChatComposer { self.collaboration_modes_enabled, self.connectors_enabled, self.personality_command_enabled, + self.realtime_conversation_enabled, self.windows_degraded_sandbox_active, ) .is_some(); @@ -2444,6 +2466,7 @@ impl ChatComposer { self.collaboration_modes_enabled, self.connectors_enabled, self.personality_command_enabled, + self.realtime_conversation_enabled, self.windows_degraded_sandbox_active, ) { @@ -2478,6 +2501,7 @@ impl ChatComposer { self.collaboration_modes_enabled, self.connectors_enabled, self.personality_command_enabled, + self.realtime_conversation_enabled, self.windows_degraded_sandbox_active, )?; @@ -3310,6 +3334,7 @@ impl ChatComposer { self.collaboration_modes_enabled, self.connectors_enabled, self.personality_command_enabled, + self.realtime_conversation_enabled, self.windows_degraded_sandbox_active, ) .is_some(); @@ -3371,6 +3396,7 @@ impl ChatComposer { self.collaboration_modes_enabled, self.connectors_enabled, self.personality_command_enabled, + self.realtime_conversation_enabled, self.windows_degraded_sandbox_active, ) { return true; @@ -3424,12 +3450,14 @@ impl ChatComposer { let collaboration_modes_enabled = self.collaboration_modes_enabled; let connectors_enabled = self.connectors_enabled; let personality_command_enabled = self.personality_command_enabled; + let realtime_conversation_enabled = self.realtime_conversation_enabled; let mut command_popup = CommandPopup::new( self.custom_prompts.clone(), CommandPopupFlags { collaboration_modes_enabled, connectors_enabled, personality_command_enabled, + realtime_conversation_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, }, ); @@ -3940,6 +3968,13 @@ impl ChatComposer { self.textarea.update_named_element_by_id(id, text) } + #[cfg(not(target_os = "linux"))] + pub fn insert_transcription_placeholder(&mut self, text: &str) -> String { + let id = self.next_id(); + self.textarea.insert_named_element(text, id.clone()); + id + } + pub fn remove_transcription_placeholder(&mut self, id: &str) { self.stop_transcription_spinner(id); let _ = self.textarea.replace_element_by_id(id, ""); @@ -5420,6 +5455,115 @@ mod tests { } } + #[test] + fn test_current_at_token_tracks_tokens_with_second_at() { + let input = "npx -y @kaeawc/auto-mobile@latest"; + let token_start = input.find("@kaeawc").expect("scoped npm package present"); + let version_at = input + .rfind("@latest") + .expect("version suffix present in scoped npm package"); + let test_cases = vec![ + (token_start, "Cursor at leading @"), + (token_start + 8, "Cursor inside scoped package name"), + (version_at, "Cursor at version @"), + (input.len(), "Cursor at end of token"), + ]; + + for (cursor_pos, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, + Some("kaeawc/auto-mobile@latest".to_string()), + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn test_current_at_token_allows_file_queries_with_second_at() { + let input = "@icons/icon@2x.png"; + let version_at = input + .rfind("@2x") + .expect("second @ in file token should be present"); + let test_cases = vec![ + (0, "Cursor at leading @"), + (8, "Cursor before second @"), + (version_at, "Cursor at second @"), + (input.len(), "Cursor at end of token"), + ]; + + for (cursor_pos, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert!( + result.is_some(), + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn test_current_at_token_ignores_mid_word_at() { + let input = "foo@bar"; + let at_pos = input.find('@').expect("@ present"); + let test_cases = vec![ + (at_pos, "Cursor at mid-word @"), + (input.len(), "Cursor at end of word containing @"), + ]; + + for (cursor_pos, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, None, + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn enter_submits_when_file_popup_has_no_selection() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let input = "npx -y @kaeawc/auto-mobile@latest"; + composer.textarea.insert_str(input); + composer.textarea.set_cursor(input.len()); + composer.sync_popups(); + + assert!(matches!(composer.active_popup, ActivePopup::File(_))); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, input), + _ => panic!("expected Submitted"), + } + } + /// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII /// char arrives next, the pending ASCII char should still be preserved and the overall input /// should submit normally (i.e. we should not misclassify this as a paste burst). diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 48b6ffedc4..83b523cc23 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -39,6 +39,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, pub(crate) personality_command_enabled: bool, + pub(crate) realtime_conversation_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, } @@ -49,6 +50,7 @@ impl CommandPopup { flags.collaboration_modes_enabled, flags.connectors_enabled, flags.personality_command_enabled, + flags.realtime_conversation_enabled, flags.windows_degraded_sandbox_active, ) .into_iter() @@ -495,6 +497,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: false, personality_command_enabled: true, + realtime_conversation_enabled: false, windows_degraded_sandbox_active: false, }, ); @@ -514,6 +517,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: false, personality_command_enabled: true, + realtime_conversation_enabled: false, windows_degraded_sandbox_active: false, }, ); @@ -533,6 +537,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: false, personality_command_enabled: false, + realtime_conversation_enabled: false, windows_degraded_sandbox_active: false, }, ); @@ -560,6 +565,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: false, personality_command_enabled: true, + realtime_conversation_enabled: false, windows_degraded_sandbox_active: false, }, ); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 2f566079aa..9a50a07831 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -163,6 +163,10 @@ pub(crate) struct SelectionViewParams { /// When absent, `side_content` is reused. pub stacked_side_content: Option>, + /// Keep side-content background colors after rendering in side-by-side mode. + /// Disabled by default so existing popups preserve their reset-background look. + pub preserve_side_content_bg: bool, + /// Called when the highlighted item changes (navigation, filter, number-key). /// Receives the *actual* item index, not the filtered/visible index. pub on_selection_changed: OnSelectionChangedCallback, @@ -189,6 +193,7 @@ impl Default for SelectionViewParams { side_content_width: SideContentWidth::default(), side_content_min_width: 0, stacked_side_content: None, + preserve_side_content_bg: false, on_selection_changed: None, on_cancel: None, } @@ -220,6 +225,7 @@ pub(crate) struct ListSelectionView { side_content_width: SideContentWidth, side_content_min_width: u16, stacked_side_content: Option>, + preserve_side_content_bg: bool, /// Called when the highlighted item changes (navigation, filter, number-key). on_selection_changed: OnSelectionChangedCallback, @@ -271,6 +277,7 @@ impl ListSelectionView { side_content_width: params.side_content_width, side_content_min_width: params.side_content_min_width, stacked_side_content: params.stacked_side_content, + preserve_side_content_bg: params.preserve_side_content_bg, on_selection_changed: params.on_selection_changed, on_cancel: params.on_cancel, }; @@ -905,15 +912,17 @@ impl Renderable for ListSelectionView { ), ); self.side_content.render(side_area, buf); - Self::force_bg_to_terminal_bg( - buf, - Rect::new( - clear_x, - outer_content_area.y, - clear_w, - outer_content_area.height, - ), - ); + if !self.preserve_side_content_bg { + Self::force_bg_to_terminal_bg( + buf, + Rect::new( + clear_x, + outer_content_area.y, + clear_w, + outer_content_area.height, + ), + ); + } } else if stacked_side_area.height > 0 { // Stacked fallback: render below the list (same as old footer_content). let clear_height = (outer_content_area.y + outer_content_area.height) @@ -1004,6 +1013,28 @@ mod tests { } } + struct StyledMarkerRenderable { + marker: &'static str, + style: Style, + height: u16, + } + + impl Renderable for StyledMarkerRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + for y in area.y..area.y.saturating_add(area.height) { + for x in area.x..area.x.saturating_add(area.width) { + if x < buf.area().width && y < buf.area().height { + buf[(x, y)].set_symbol(self.marker).set_style(self.style); + } + } + } + } + + fn desired_height(&self, _width: u16) -> u16 { + self.height + } + } + fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -1143,6 +1174,58 @@ mod tests { assert!(rendered.contains("Move up/down to live preview themes")); } + #[test] + fn theme_picker_enables_side_content_background_preservation() { + let params = crate::theme_picker::build_theme_picker_params(None, None, Some(120)); + assert!( + params.preserve_side_content_bg, + "theme picker should preserve side-content backgrounds to keep diff preview styling", + ); + } + + #[test] + fn preserve_side_content_bg_keeps_rendered_background_colors() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(StyledMarkerRenderable { + marker: "+", + style: Style::default().bg(Color::Blue), + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + preserve_side_content_bg: true, + ..Default::default() + }, + tx, + ); + let area = Rect::new(0, 0, 120, 35); + let mut buf = Buffer::empty(area); + + view.render(area, &mut buf); + + let plus_bg = (0..area.height) + .flat_map(|y| (0..area.width).map(move |x| (x, y))) + .find_map(|(x, y)| { + let cell = &buf[(x, y)]; + (cell.symbol() == "+").then(|| cell.style().bg) + }) + .expect("expected side content to render at least one '+' marker"); + assert_eq!( + plus_bg, + Some(Color::Blue), + "expected side-content marker to preserve custom background styling", + ); + } + #[test] fn snapshot_footer_note_wraps() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 28cd1ddc72..6801292360 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -292,6 +292,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) { + self.composer.set_realtime_conversation_enabled(enabled); + self.request_redraw(); + } + pub fn set_voice_transcription_enabled(&mut self, enabled: bool) { self.composer.set_voice_transcription_enabled(enabled); self.request_redraw(); @@ -1007,6 +1012,13 @@ impl BottomPane { #[cfg(not(target_os = "linux"))] impl BottomPane { + pub(crate) fn insert_transcription_placeholder(&mut self, text: &str) -> String { + let id = self.composer.insert_transcription_placeholder(text); + self.composer.sync_popups(); + self.request_redraw(); + id + } + pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) { self.composer.replace_transcription(id, text); self.composer.sync_popups(); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 736eb3102b..86c131e6db 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -13,6 +13,7 @@ pub(crate) fn builtins_for_input( collaboration_modes_enabled: bool, connectors_enabled: bool, personality_command_enabled: bool, + realtime_conversation_enabled: bool, allow_elevate_sandbox: bool, ) -> Vec<(&'static str, SlashCommand)> { built_in_slash_commands() @@ -24,6 +25,7 @@ pub(crate) fn builtins_for_input( }) .filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality) + .filter(|(_, cmd)| realtime_conversation_enabled || *cmd != SlashCommand::Realtime) .collect() } @@ -33,12 +35,14 @@ pub(crate) fn find_builtin_command( collaboration_modes_enabled: bool, connectors_enabled: bool, personality_command_enabled: bool, + realtime_conversation_enabled: bool, allow_elevate_sandbox: bool, ) -> Option { builtins_for_input( collaboration_modes_enabled, connectors_enabled, personality_command_enabled, + realtime_conversation_enabled, allow_elevate_sandbox, ) .into_iter() @@ -52,12 +56,14 @@ pub(crate) fn has_builtin_prefix( collaboration_modes_enabled: bool, connectors_enabled: bool, personality_command_enabled: bool, + realtime_conversation_enabled: bool, allow_elevate_sandbox: bool, ) -> bool { builtins_for_input( collaboration_modes_enabled, connectors_enabled, personality_command_enabled, + realtime_conversation_enabled, allow_elevate_sandbox, ) .into_iter() @@ -71,15 +77,23 @@ mod tests { #[test] fn debug_command_still_resolves_for_dispatch() { - let cmd = find_builtin_command("debug-config", true, true, true, false); + let cmd = find_builtin_command("debug-config", true, true, true, false, false); assert_eq!(cmd, Some(SlashCommand::DebugConfig)); } #[test] fn clear_command_resolves_for_dispatch() { assert_eq!( - find_builtin_command("clear", true, true, true, false), + find_builtin_command("clear", true, true, true, false, false), Some(SlashCommand::Clear) ); } + + #[test] + fn realtime_command_is_hidden_when_realtime_is_disabled() { + assert_eq!( + find_builtin_command("realtime", true, true, true, false, false), + None + ); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a67cc2d4bf..ac8da4d55d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -218,6 +218,7 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; +use crate::clipboard_text; use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -259,6 +260,9 @@ mod skills; use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; +mod realtime; +use self::realtime::RealtimeConversationUiState; +use self::realtime::RenderedUserMessageEvent; use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::streaming::chunking::AdaptiveChunkingPolicy; @@ -553,6 +557,8 @@ pub(crate) struct ChatWidget { stream_controller: Option, // Stream lifecycle controller for proposed plan output. plan_stream_controller: Option, + // Latest completed user-visible Codex output that `/copy` should place on the clipboard. + last_copyable_output: Option, running_commands: HashMap, suppressed_exec_calls: HashSet, skills_all: Vec, @@ -665,6 +671,8 @@ pub(crate) struct ChatWidget { // True once we've attempted a branch lookup for the current CWD. status_line_branch_lookup_complete: bool, external_editor_state: ExternalEditorState, + realtime_conversation: RealtimeConversationUiState, + last_rendered_user_message_event: Option, } /// Snapshot of active-cell state that affects transcript overlay rendering. @@ -842,6 +850,11 @@ enum ReplayKind { } impl ChatWidget { + fn realtime_conversation_enabled(&self) -> bool { + self.config.features.enabled(Feature::RealtimeConversation) + && cfg!(not(target_os = "linux")) + } + /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. /// /// The bottom pane only has one running flag, but this module treats it as a derived state of @@ -1113,6 +1126,7 @@ impl ChatWidget { Constrained::allow_only(event.sandbox_policy.clone()); } let initial_messages = event.initial_messages.clone(); + self.last_copyable_output = None; let forked_from_id = event.forked_from_id; let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); @@ -1301,6 +1315,9 @@ impl ChatWidget { } else { text }; + if !plan_text.trim().is_empty() { + self.last_copyable_output = Some(plan_text.clone()); + } // Plan commit ticks can hide the status row; remember whether we streamed plan output so // completion can restore it once stream queues are idle. let should_restore_after_stream = self.plan_stream_controller.is_some(); @@ -1394,6 +1411,11 @@ impl ChatWidget { } fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { + if let Some(message) = last_agent_message.as_ref() + && !message.trim().is_empty() + { + self.last_copyable_output = Some(message.clone()); + } // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); if let Some(mut controller) = self.plan_stream_controller.take() @@ -2797,6 +2819,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + last_copyable_output: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -2847,6 +2870,8 @@ impl ChatWidget { status_line_branch_pending: false, status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, + realtime_conversation: RealtimeConversationUiState::default(), + last_rendered_user_message_event: None, }; widget.prefetch_rate_limits(); @@ -2856,6 +2881,9 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget + .bottom_pane + .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); @@ -2968,6 +2996,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + last_copyable_output: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -3018,6 +3047,8 @@ impl ChatWidget { status_line_branch_pending: false, status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, + realtime_conversation: RealtimeConversationUiState::default(), + last_rendered_user_message_event: None, }; widget.prefetch_rate_limits(); @@ -3027,6 +3058,9 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget + .bottom_pane + .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); @@ -3128,6 +3162,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + last_copyable_output: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -3178,6 +3213,8 @@ impl ChatWidget { status_line_branch_pending: false, status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, + realtime_conversation: RealtimeConversationUiState::default(), + last_rendered_user_message_event: None, }; widget.prefetch_rate_limits(); @@ -3187,6 +3224,9 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget + .bottom_pane + .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); @@ -3308,6 +3348,11 @@ impl ChatWidget { .bottom_pane .take_recent_submission_mention_bindings(), }; + let Some(user_message) = + self.maybe_defer_user_message_for_realtime(user_message) + else { + return; + }; // Steer submissions during active final-answer streaming can race with turn // completion and strand the UI in a running state. Queue those inputs instead // of injecting immediately; `on_task_complete()` drains this FIFO via @@ -3343,6 +3388,11 @@ impl ChatWidget { .bottom_pane .take_recent_submission_mention_bindings(), }; + let Some(user_message) = + self.maybe_defer_user_message_for_realtime(user_message) + else { + return; + }; self.queue_user_message(user_message); } InputResult::Command(cmd) => { @@ -3479,6 +3529,16 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Realtime => { + if !self.realtime_conversation_enabled() { + return; + } + if self.realtime_conversation.is_live() { + self.request_realtime_conversation_close(None); + } else { + self.start_realtime_conversation(); + } + } SlashCommand::Personality => { self.open_personality_popup(); } @@ -3605,6 +3665,34 @@ impl ChatWidget { tx.send(AppEvent::DiffResult(text)); }); } + SlashCommand::Copy => { + let Some(text) = self.last_copyable_output.as_deref() else { + self.add_info_message( + "`/copy` is unavailable before the first Codex output or right after a rollback." + .to_string(), + None, + ); + return; + }; + + let copy_result = clipboard_text::copy_text_to_clipboard(text); + + match copy_result { + Ok(()) => { + let hint = self.agent_turn_running.then_some( + "Current turn is still running; copied the latest completed output (not the in-progress response)." + .to_string(), + ); + self.add_info_message( + "Copied latest Codex output to clipboard.".to_string(), + hint, + ); + } + Err(err) => { + self.add_error_message(format!("Failed to copy to clipboard: {err}")) + } + } + } SlashCommand::Mention => { self.insert_str("@"); } @@ -4086,7 +4174,17 @@ impl ChatWidget { // Show replayable user content in conversation history. if !text.is_empty() { - let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + let local_image_paths = local_images + .into_iter() + .map(|img| img.path) + .collect::>(); + self.last_rendered_user_message_event = + Some(Self::rendered_user_message_event_from_parts( + text.clone(), + text_elements.clone(), + local_image_paths.clone(), + remote_image_urls.clone(), + )); self.add_to_history(history_cell::new_user_prompt( text, text_elements, @@ -4094,6 +4192,13 @@ impl ChatWidget { remote_image_urls, )); } else if !remote_image_urls.is_empty() { + self.last_rendered_user_message_event = + Some(Self::rendered_user_message_event_from_parts( + String::new(), + Vec::new(), + Vec::new(), + remote_image_urls.clone(), + )); self.add_to_history(history_cell::new_user_prompt( String::new(), Vec::new(), @@ -4316,7 +4421,7 @@ impl ChatWidget { } } EventMsg::UserMessage(ev) => { - if from_replay { + if from_replay || self.should_render_realtime_user_message_event(&ev) { self.on_user_message_event(ev); } } @@ -4340,6 +4445,10 @@ impl ChatWidget { EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), EventMsg::ThreadRolledBack(rollback) => { + // Conservatively clear `/copy` state on rollback. The app layer trims visible + // transcript cells, but we do not maintain rollback-aware raw-markdown history yet, + // so keeping the previous cache can return content that was just removed. + self.last_copyable_output = None; if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { num_turns: rollback.num_turns, @@ -4351,11 +4460,23 @@ impl ChatWidget { | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::RealtimeConversationStarted(_) - | EventMsg::RealtimeConversationRealtime(_) - | EventMsg::RealtimeConversationClosed(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::SkillRequestApproval(_) => {} + EventMsg::RealtimeConversationStarted(ev) => { + if !from_replay { + self.on_realtime_conversation_started(ev); + } + } + EventMsg::RealtimeConversationRealtime(ev) => { + if !from_replay { + self.on_realtime_conversation_realtime(ev); + } + } + EventMsg::RealtimeConversationClosed(ev) => { + if !from_replay { + self.on_realtime_conversation_closed(ev); + } + } EventMsg::ItemCompleted(event) => { let item = event.item; if let codex_protocol::items::TurnItem::Plan(plan_item) = &item { @@ -4426,6 +4547,8 @@ impl ChatWidget { } fn on_user_message_event(&mut self, event: UserMessageEvent) { + self.last_rendered_user_message_event = + Some(Self::rendered_user_message_event_from_event(&event)); let remote_image_urls = event.images.unwrap_or_default(); if !event.message.trim().is_empty() || !event.text_elements.is_empty() @@ -6401,6 +6524,17 @@ impl ChatWidget { if feature == Feature::VoiceTranscription { self.bottom_pane.set_voice_transcription_enabled(enabled); } + if feature == Feature::RealtimeConversation { + let realtime_conversation_enabled = self.realtime_conversation_enabled(); + self.bottom_pane + .set_realtime_conversation_enabled(realtime_conversation_enabled); + if !realtime_conversation_enabled && self.realtime_conversation.is_live() { + self.request_realtime_conversation_close(Some( + "Realtime voice mode was closed because the feature was disabled.".to_string(), + )); + self.reset_realtime_conversation_state(); + } + } if feature == Feature::Personality { self.sync_personality_command_enabled(); } @@ -7588,6 +7722,7 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool { impl Drop for ChatWidget { fn drop(&mut self) { + self.reset_realtime_conversation_state(); self.stop_rate_limit_poller(); } } diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs new file mode 100644 index 0000000000..2cf97188b3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -0,0 +1,298 @@ +use super::*; +use codex_protocol::protocol::ConversationStartParams; +use codex_protocol::protocol::RealtimeAudioFrame; +use codex_protocol::protocol::RealtimeConversationClosedEvent; +use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +use codex_protocol::protocol::RealtimeConversationStartedEvent; +use codex_protocol::protocol::RealtimeEvent; + +const REALTIME_CONVERSATION_PROMPT: &str = "You are in a realtime voice conversation in the Codex TUI. Respond conversationally and concisely."; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum RealtimeConversationPhase { + #[default] + Inactive, + Starting, + Active, + Stopping, +} + +#[derive(Default)] +pub(super) struct RealtimeConversationUiState { + phase: RealtimeConversationPhase, + requested_close: bool, + session_id: Option, + warned_audio_only_submission: bool, + meter_placeholder_id: Option, + #[cfg(not(target_os = "linux"))] + capture_stop_flag: Option>, + #[cfg(not(target_os = "linux"))] + capture: Option, + #[cfg(not(target_os = "linux"))] + audio_player: Option, +} + +impl RealtimeConversationUiState { + pub(super) fn is_live(&self) -> bool { + matches!( + self.phase, + RealtimeConversationPhase::Starting + | RealtimeConversationPhase::Active + | RealtimeConversationPhase::Stopping + ) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct RenderedUserMessageEvent { + message: String, + remote_image_urls: Vec, + local_images: Vec, + text_elements: Vec, +} + +impl ChatWidget { + pub(super) fn rendered_user_message_event_from_parts( + message: String, + text_elements: Vec, + local_images: Vec, + remote_image_urls: Vec, + ) -> RenderedUserMessageEvent { + RenderedUserMessageEvent { + message, + remote_image_urls, + local_images, + text_elements, + } + } + + pub(super) fn rendered_user_message_event_from_event( + event: &UserMessageEvent, + ) -> RenderedUserMessageEvent { + Self::rendered_user_message_event_from_parts( + event.message.clone(), + event.text_elements.clone(), + event.local_images.clone(), + event.images.clone().unwrap_or_default(), + ) + } + + pub(super) fn should_render_realtime_user_message_event( + &self, + event: &UserMessageEvent, + ) -> bool { + if !self.realtime_conversation.is_live() { + return false; + } + let key = Self::rendered_user_message_event_from_event(event); + self.last_rendered_user_message_event.as_ref() != Some(&key) + } + + pub(super) fn maybe_defer_user_message_for_realtime( + &mut self, + user_message: UserMessage, + ) -> Option { + if !self.realtime_conversation.is_live() { + return Some(user_message); + } + + self.restore_user_message_to_composer(user_message); + if !self.realtime_conversation.warned_audio_only_submission { + self.realtime_conversation.warned_audio_only_submission = true; + self.add_info_message( + "Realtime voice mode is audio-only. Use /realtime to stop.".to_string(), + None, + ); + } else { + self.request_redraw(); + } + + None + } + + fn realtime_footer_hint_items() -> Vec<(String, String)> { + vec![("/realtime".to_string(), "stop live voice".to_string())] + } + + pub(super) fn start_realtime_conversation(&mut self) { + self.realtime_conversation.phase = RealtimeConversationPhase::Starting; + self.realtime_conversation.requested_close = false; + self.realtime_conversation.session_id = None; + self.realtime_conversation.warned_audio_only_submission = false; + self.set_footer_hint_override(Some(Self::realtime_footer_hint_items())); + self.submit_op(Op::RealtimeConversationStart(ConversationStartParams { + prompt: REALTIME_CONVERSATION_PROMPT.to_string(), + session_id: None, + })); + self.request_redraw(); + } + + pub(super) fn request_realtime_conversation_close(&mut self, info_message: Option) { + if !self.realtime_conversation.is_live() { + if let Some(message) = info_message { + self.add_info_message(message, None); + } + return; + } + + self.realtime_conversation.requested_close = true; + self.realtime_conversation.phase = RealtimeConversationPhase::Stopping; + self.submit_op(Op::RealtimeConversationClose); + self.stop_realtime_local_audio(); + self.set_footer_hint_override(None); + + if let Some(message) = info_message { + self.add_info_message(message, None); + } else { + self.request_redraw(); + } + } + + pub(super) fn reset_realtime_conversation_state(&mut self) { + self.stop_realtime_local_audio(); + self.set_footer_hint_override(None); + self.realtime_conversation.phase = RealtimeConversationPhase::Inactive; + self.realtime_conversation.requested_close = false; + self.realtime_conversation.session_id = None; + self.realtime_conversation.warned_audio_only_submission = false; + } + + pub(super) fn on_realtime_conversation_started( + &mut self, + ev: RealtimeConversationStartedEvent, + ) { + if !self.realtime_conversation_enabled() { + self.submit_op(Op::RealtimeConversationClose); + self.reset_realtime_conversation_state(); + return; + } + self.realtime_conversation.phase = RealtimeConversationPhase::Active; + self.realtime_conversation.session_id = ev.session_id; + self.realtime_conversation.warned_audio_only_submission = false; + self.set_footer_hint_override(Some(Self::realtime_footer_hint_items())); + self.start_realtime_local_audio(); + self.request_redraw(); + } + + pub(super) fn on_realtime_conversation_realtime( + &mut self, + ev: RealtimeConversationRealtimeEvent, + ) { + match ev.payload { + RealtimeEvent::SessionCreated { session_id } => { + self.realtime_conversation.session_id = Some(session_id); + } + RealtimeEvent::SessionUpdated { .. } => {} + RealtimeEvent::AudioOut(frame) => self.enqueue_realtime_audio_out(&frame), + RealtimeEvent::ConversationItemAdded(_item) => {} + RealtimeEvent::Error(message) => { + self.add_error_message(format!("Realtime voice error: {message}")); + self.reset_realtime_conversation_state(); + } + } + } + + pub(super) fn on_realtime_conversation_closed(&mut self, ev: RealtimeConversationClosedEvent) { + let requested = self.realtime_conversation.requested_close; + let reason = ev.reason; + self.reset_realtime_conversation_state(); + if !requested && let Some(reason) = reason { + self.add_info_message(format!("Realtime voice mode closed: {reason}"), None); + } + self.request_redraw(); + } + + fn enqueue_realtime_audio_out(&mut self, frame: &RealtimeAudioFrame) { + #[cfg(not(target_os = "linux"))] + { + if self.realtime_conversation.audio_player.is_none() { + self.realtime_conversation.audio_player = + crate::voice::RealtimeAudioPlayer::start().ok(); + } + if let Some(player) = &self.realtime_conversation.audio_player + && let Err(err) = player.enqueue_frame(frame) + { + warn!("failed to play realtime audio: {err}"); + } + } + #[cfg(target_os = "linux")] + { + let _ = frame; + } + } + + #[cfg(not(target_os = "linux"))] + fn start_realtime_local_audio(&mut self) { + if self.realtime_conversation.capture_stop_flag.is_some() { + return; + } + + let placeholder_id = self.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); + self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); + self.request_redraw(); + + let capture = match crate::voice::VoiceCapture::start_realtime(self.app_event_tx.clone()) { + Ok(capture) => capture, + Err(err) => { + self.remove_transcription_placeholder(&placeholder_id); + self.realtime_conversation.meter_placeholder_id = None; + self.add_error_message(format!("Failed to start microphone capture: {err}")); + return; + } + }; + + let stop_flag = capture.stopped_flag(); + let peak = capture.last_peak_arc(); + let meter_placeholder_id = placeholder_id; + let app_event_tx = self.app_event_tx.clone(); + + self.realtime_conversation.capture_stop_flag = Some(stop_flag.clone()); + self.realtime_conversation.capture = Some(capture); + if self.realtime_conversation.audio_player.is_none() { + self.realtime_conversation.audio_player = + crate::voice::RealtimeAudioPlayer::start().ok(); + } + + std::thread::spawn(move || { + let mut meter = crate::voice::RecordingMeterState::new(); + + loop { + if stop_flag.load(Ordering::Relaxed) { + break; + } + + let meter_text = meter.next_text(peak.load(Ordering::Relaxed)); + app_event_tx.send(AppEvent::UpdateRecordingMeter { + id: meter_placeholder_id.clone(), + text: meter_text, + }); + + std::thread::sleep(Duration::from_millis(60)); + } + }); + } + + #[cfg(target_os = "linux")] + fn start_realtime_local_audio(&mut self) {} + + #[cfg(not(target_os = "linux"))] + fn stop_realtime_local_audio(&mut self) { + if let Some(flag) = self.realtime_conversation.capture_stop_flag.take() { + flag.store(true, Ordering::Relaxed); + } + if let Some(capture) = self.realtime_conversation.capture.take() { + let _ = capture.stop(); + } + if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() { + self.remove_transcription_placeholder(&id); + } + if let Some(player) = self.realtime_conversation.audio_player.take() { + player.clear(); + } + } + + #[cfg(target_os = "linux")] + fn stop_realtime_local_audio(&mut self) { + self.realtime_conversation.meter_placeholder_id = None; + } +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap new file mode 100644 index 0000000000..7e24b570ee --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 4430 +expression: rendered.clone() +--- +• `/copy` is unavailable before the first Codex output or right after a rollback. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8f6bf71769..5fa9854138 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -37,6 +37,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::Settings; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; use codex_protocol::models::MessagePhase; use codex_protocol::openai_models::ModelPreset; @@ -1667,6 +1668,7 @@ async fn make_chatwidget_manual( adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + last_copyable_output: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), skills_all: Vec::new(), @@ -1720,6 +1722,8 @@ async fn make_chatwidget_manual( status_line_branch_pending: false, status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, + realtime_conversation: RealtimeConversationUiState::default(), + last_rendered_user_message_event: None, }; widget.set_model(&resolved_model); (widget, rx, op_rx) @@ -4622,6 +4626,202 @@ async fn slash_quit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn slash_copy_state_tracks_turn_complete_final_reply() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Final reply **markdown**".to_string()), + }), + }); + + assert_eq!( + chat.last_copyable_output, + Some("Final reply **markdown**".to_string()) + ); +} + +#[tokio::test] +async fn slash_copy_state_tracks_plan_item_completion() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string(); + + chat.handle_codex_event(Event { + id: "item-plan".into(), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + item: TurnItem::Plan(PlanItem { + id: "plan-1".to_string(), + text: plan_text.clone(), + }), + }), + }); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + assert_eq!(chat.last_copyable_output, Some(plan_text)); +} + +#[tokio::test] +async fn slash_copy_reports_when_no_copyable_output_exists() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Copy); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert_snapshot!("slash_copy_no_output_info_message", rendered); + assert!( + rendered.contains( + "`/copy` is unavailable before the first Codex output or right after a rollback." + ), + "expected no-output message, got {rendered:?}" + ); +} + +#[tokio::test] +async fn slash_copy_state_is_preserved_during_running_task() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Previous completed reply".to_string()), + }), + }); + chat.on_task_started(); + + assert_eq!( + chat.last_copyable_output, + Some("Previous completed reply".to_string()) + ); +} + +#[tokio::test] +async fn slash_copy_state_clears_on_thread_rollback() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Reply that will be rolled back".to_string()), + }), + }); + chat.handle_codex_event(Event { + id: "rollback-1".into(), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + }); + + assert_eq!(chat.last_copyable_output, None); +} + +#[tokio::test] +async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_turn_complete() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Legacy final message".into(), + phase: None, + }), + }); + let _ = drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + let _ = drain_insert_history(&mut rx); + + chat.dispatch_command(SlashCommand::Copy); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains( + "`/copy` is unavailable before the first Codex output or right after a rollback." + ), + "expected unavailable message, got {rendered:?}" + ); +} + +#[tokio::test] +async fn slash_copy_is_unavailable_when_legacy_agent_message_item_is_not_repeated_on_turn_complete() +{ + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + complete_assistant_message(&mut chat, "msg-1", "Legacy item final message", None); + let _ = drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + let _ = drain_insert_history(&mut rx); + + chat.dispatch_command(SlashCommand::Copy); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains( + "`/copy` is unavailable before the first Codex output or right after a rollback." + ), + "expected unavailable message, got {rendered:?}" + ); +} + +#[tokio::test] +async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Reply that will be rolled back".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + + chat.handle_codex_event(Event { + id: "rollback-1".into(), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + }); + let _ = drain_insert_history(&mut rx); + + chat.dispatch_command(SlashCommand::Copy); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains( + "`/copy` is unavailable before the first Codex output or right after a rollback." + ), + "expected rollback-cleared copy state message, got {rendered:?}" + ); +} + #[tokio::test] async fn slash_exit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/clipboard_text.rs b/codex-rs/tui/src/clipboard_text.rs new file mode 100644 index 0000000000..ec8a7b112d --- /dev/null +++ b/codex-rs/tui/src/clipboard_text.rs @@ -0,0 +1,11 @@ +#[cfg(not(target_os = "android"))] +pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> { + let mut cb = arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; + cb.set_text(text.to_string()) + .map_err(|e| format!("clipboard unavailable: {e}")) +} + +#[cfg(target_os = "android")] +pub fn copy_text_to_clipboard(_text: &str) -> Result<(), String> { + Err("clipboard text copy is unsupported on Android".into()) +} diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index aa0481d044..32591945fa 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -7,6 +7,13 @@ //! is present, the content text is syntax-highlighted using //! [`crate::render::highlight`]. //! +//! **Theme-aware styling:** diff backgrounds adapt to the terminal's +//! background lightness via [`DiffTheme`]. Dark terminals get muted tints +//! (`#212922` green, `#3C170F` red); light terminals get GitHub-style pastels +//! with distinct gutter backgrounds for contrast. The renderer uses fixed +//! palettes for truecolor / 256-color / 16-color terminals so add/delete lines +//! remain visually distinct even when quantizing to limited palettes. +//! //! **Highlighting strategy for `Update` diffs:** the renderer highlights each //! hunk as a single concatenated block rather than line-by-line. This //! preserves syntect's parser state across consecutive lines within a hunk @@ -37,6 +44,31 @@ use unicode_width::UnicodeWidthChar; /// Display width of a tab character in columns. const TAB_WIDTH: usize = 4; +// -- Diff background palette -------------------------------------------------- +// +// Dark-theme tints are subtle enough to avoid clashing with syntax colors. +// Light-theme values match GitHub's diff colors for familiarity. The gutter +// (line-number column) uses slightly more saturated variants on light +// backgrounds so the numbers remain readable against the pastel line background. +// Truecolor palette. +const DARK_TC_ADD_LINE_BG_RGB: (u8, u8, u8) = (33, 58, 43); // #213A2B +const DARK_TC_DEL_LINE_BG_RGB: (u8, u8, u8) = (74, 34, 29); // #4A221D +const LIGHT_TC_ADD_LINE_BG_RGB: (u8, u8, u8) = (218, 251, 225); // #dafbe1 +const LIGHT_TC_DEL_LINE_BG_RGB: (u8, u8, u8) = (255, 235, 233); // #ffebe9 +const LIGHT_TC_ADD_NUM_BG_RGB: (u8, u8, u8) = (172, 238, 187); // #aceebb +const LIGHT_TC_DEL_NUM_BG_RGB: (u8, u8, u8) = (255, 206, 203); // #ffcecb +const LIGHT_TC_GUTTER_FG_RGB: (u8, u8, u8) = (31, 35, 40); // #1f2328 + +// 256-color palette. +const DARK_256_ADD_LINE_BG_IDX: u8 = 22; +const DARK_256_DEL_LINE_BG_IDX: u8 = 52; +const LIGHT_256_ADD_LINE_BG_IDX: u8 = 194; +const LIGHT_256_DEL_LINE_BG_IDX: u8 = 224; +const LIGHT_256_ADD_NUM_BG_IDX: u8 = 157; +const LIGHT_256_DEL_NUM_BG_IDX: u8 = 217; +const LIGHT_256_GUTTER_FG_IDX: u8 = 236; + +use crate::color::is_light; use crate::exec_command::relativize_to_home; use crate::render::Insets; use crate::render::highlight::exceeds_highlight_limits; @@ -45,6 +77,11 @@ use crate::render::line_utils::prefix_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::default_bg; +use crate::terminal_palette::indexed_color; +use crate::terminal_palette::rgb_color; +use crate::terminal_palette::stdout_color_level; use codex_core::git_info::get_git_repo_root; use codex_protocol::protocol::FileChange; @@ -60,6 +97,26 @@ pub(crate) enum DiffLineType { Context, } +/// Controls which color palette the diff renderer uses for backgrounds and +/// gutter styling. +/// +/// Determined once per `render_change` call via [`diff_theme`], which probes +/// the terminal's queried background color. When the background cannot be +/// determined (common in CI or piped output), `Dark` is used as the safe +/// default. +#[derive(Clone, Copy)] +enum DiffTheme { + Dark, + Light, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DiffColorLevel { + TrueColor, + Ansi256, + Ansi16, +} + pub struct DiffSummary { changes: HashMap, cwd: PathBuf, @@ -243,6 +300,8 @@ fn render_change( width: usize, lang: Option<&str>, ) { + let theme = diff_theme(); + let color_level = diff_color_level(); match change { FileChange::Add { content } => { // Pre-highlight the entire file content as a whole. @@ -251,21 +310,26 @@ fn render_change( for (i, raw) in content.lines().enumerate() { let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i)); if let Some(spans) = syn { - out.extend(push_wrapped_diff_line_with_syntax( + out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level( i + 1, DiffLineType::Insert, raw, width, line_number_width, - spans, + Some(spans), + theme, + color_level, )); } else { - out.extend(push_wrapped_diff_line( + out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level( i + 1, DiffLineType::Insert, raw, width, line_number_width, + None, + theme, + color_level, )); } } @@ -276,21 +340,26 @@ fn render_change( for (i, raw) in content.lines().enumerate() { let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i)); if let Some(spans) = syn { - out.extend(push_wrapped_diff_line_with_syntax( + out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level( i + 1, DiffLineType::Delete, raw, width, line_number_width, - spans, + Some(spans), + theme, + color_level, )); } else { - out.extend(push_wrapped_diff_line( + out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level( i + 1, DiffLineType::Delete, raw, width, line_number_width, + None, + theme, + color_level, )); } } @@ -343,7 +412,10 @@ fn render_change( for h in patch.hunks() { if !is_first_hunk { let spacer = format!("{:width$} ", "", width = line_number_width.max(1)); - let spacer_span = RtSpan::styled(spacer, style_gutter()); + let spacer_span = RtSpan::styled( + spacer, + style_gutter_for(DiffLineType::Context, theme, color_level), + ); out.push(RtLine::from(vec![spacer_span, "⋮".dim()])); } is_first_hunk = false; @@ -374,66 +446,93 @@ fn render_change( diffy::Line::Insert(text) => { let s = text.trim_end_matches('\n'); if let Some(syn) = syntax_spans { - out.extend(push_wrapped_diff_line_with_syntax( - new_ln, - DiffLineType::Insert, - s, - width, - line_number_width, - syn, - )); + out.extend( + push_wrapped_diff_line_inner_with_theme_and_color_level( + new_ln, + DiffLineType::Insert, + s, + width, + line_number_width, + Some(syn), + theme, + color_level, + ), + ); } else { - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Insert, - s, - width, - line_number_width, - )); + out.extend( + push_wrapped_diff_line_inner_with_theme_and_color_level( + new_ln, + DiffLineType::Insert, + s, + width, + line_number_width, + None, + theme, + color_level, + ), + ); } new_ln += 1; } diffy::Line::Delete(text) => { let s = text.trim_end_matches('\n'); if let Some(syn) = syntax_spans { - out.extend(push_wrapped_diff_line_with_syntax( - old_ln, - DiffLineType::Delete, - s, - width, - line_number_width, - syn, - )); + out.extend( + push_wrapped_diff_line_inner_with_theme_and_color_level( + old_ln, + DiffLineType::Delete, + s, + width, + line_number_width, + Some(syn), + theme, + color_level, + ), + ); } else { - out.extend(push_wrapped_diff_line( - old_ln, - DiffLineType::Delete, - s, - width, - line_number_width, - )); + out.extend( + push_wrapped_diff_line_inner_with_theme_and_color_level( + old_ln, + DiffLineType::Delete, + s, + width, + line_number_width, + None, + theme, + color_level, + ), + ); } old_ln += 1; } diffy::Line::Context(text) => { let s = text.trim_end_matches('\n'); if let Some(syn) = syntax_spans { - out.extend(push_wrapped_diff_line_with_syntax( - new_ln, - DiffLineType::Context, - s, - width, - line_number_width, - syn, - )); + out.extend( + push_wrapped_diff_line_inner_with_theme_and_color_level( + new_ln, + DiffLineType::Context, + s, + width, + line_number_width, + Some(syn), + theme, + color_level, + ), + ); } else { - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Context, - s, - width, - line_number_width, - )); + out.extend( + push_wrapped_diff_line_inner_with_theme_and_color_level( + new_ln, + DiffLineType::Context, + s, + width, + line_number_width, + None, + theme, + color_level, + ), + ); } old_ln += 1; new_ln += 1; @@ -530,6 +629,56 @@ fn push_wrapped_diff_line_inner( width: usize, line_number_width: usize, syntax_spans: Option<&[RtSpan<'static>]>, +) -> Vec> { + push_wrapped_diff_line_inner_with_theme( + line_number, + kind, + text, + width, + line_number_width, + syntax_spans, + diff_theme(), + ) +} + +/// Core line renderer: builds one or more wrapped `RtLine`s for a single diff +/// line, applying gutter, sign, content, and full-width background styles +/// according to the given `theme`. +/// +/// Split out from `push_wrapped_diff_line_inner` so that tests can exercise +/// specific `DiffTheme` variants without depending on the terminal's queried +/// background. +fn push_wrapped_diff_line_inner_with_theme( + line_number: usize, + kind: DiffLineType, + text: &str, + width: usize, + line_number_width: usize, + syntax_spans: Option<&[RtSpan<'static>]>, + theme: DiffTheme, +) -> Vec> { + push_wrapped_diff_line_inner_with_theme_and_color_level( + line_number, + kind, + text, + width, + line_number_width, + syntax_spans, + theme, + diff_color_level(), + ) +} + +#[allow(clippy::too_many_arguments)] +fn push_wrapped_diff_line_inner_with_theme_and_color_level( + line_number: usize, + kind: DiffLineType, + text: &str, + width: usize, + line_number_width: usize, + syntax_spans: Option<&[RtSpan<'static>]>, + theme: DiffTheme, + color_level: DiffColorLevel, ) -> Vec> { let ln_str = line_number.to_string(); @@ -538,28 +687,37 @@ fn push_wrapped_diff_line_inner( let gutter_width = line_number_width.max(1); let prefix_cols = gutter_width + 1; - let (sign_char, line_style) = match kind { - DiffLineType::Insert => ('+', style_add()), - DiffLineType::Delete => ('-', style_del()), - DiffLineType::Context => (' ', style_context()), + let (sign_char, sign_style, content_style) = match kind { + DiffLineType::Insert => ( + '+', + style_sign_add(theme, color_level), + style_add(theme, color_level), + ), + DiffLineType::Delete => ( + '-', + style_sign_del(theme, color_level), + style_del(theme, color_level), + ), + DiffLineType::Context => (' ', style_context(), style_context()), }; + let line_bg = style_line_bg_for(kind, theme, color_level); + let gutter_style = style_gutter_for(kind, theme, color_level); + // When we have syntax spans, compose them with the diff style for a richer // view. The sign character keeps the diff color; content gets syntax colors // with an overlay modifier for delete lines (dim). if let Some(syn_spans) = syntax_spans { let gutter = format!("{ln_str:>gutter_width$} "); let sign = format!("{sign_char}"); - let dim_overlay = matches!(kind, DiffLineType::Delete); - - // Apply dim overlay to syntax spans if this is a delete line. let styled: Vec> = syn_spans .iter() .map(|sp| { - let mut style = sp.style; - if dim_overlay { - style.add_modifier |= Modifier::DIM; - } + let style = if matches!(kind, DiffLineType::Delete) { + sp.style.add_modifier(Modifier::DIM) + } else { + sp.style + }; RtSpan::styled(sp.content.clone().into_owned(), style) }) .collect(); @@ -576,22 +734,22 @@ fn push_wrapped_diff_line_inner( let mut row_spans: Vec> = Vec::new(); if i == 0 { // First line: gutter + sign + content - row_spans.push(RtSpan::styled(gutter.clone(), style_gutter())); - row_spans.push(RtSpan::styled(sign.clone(), line_style)); + row_spans.push(RtSpan::styled(gutter.clone(), gutter_style)); + row_spans.push(RtSpan::styled(sign.clone(), sign_style)); } else { // Continuation: empty gutter + two-space indent (matches // the plain-text wrapping continuation style). let cont_gutter = format!("{:gutter_width$} ", ""); - row_spans.push(RtSpan::styled(cont_gutter, style_gutter())); + row_spans.push(RtSpan::styled(cont_gutter, gutter_style)); } row_spans.extend(chunk); - lines.push(RtLine::from(row_spans)); + lines.push(RtLine::from(row_spans).style(line_bg)); } return lines; } let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1); - let styled = vec![RtSpan::styled(text.to_string(), line_style)]; + let styled = vec![RtSpan::styled(text.to_string(), content_style)]; let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols); let mut lines: Vec> = Vec::new(); @@ -600,14 +758,14 @@ fn push_wrapped_diff_line_inner( if i == 0 { let gutter = format!("{ln_str:>gutter_width$} "); let sign = format!("{sign_char}"); - row_spans.push(RtSpan::styled(gutter, style_gutter())); - row_spans.push(RtSpan::styled(sign, line_style)); + row_spans.push(RtSpan::styled(gutter, gutter_style)); + row_spans.push(RtSpan::styled(sign, sign_style)); } else { let cont_gutter = format!("{:gutter_width$} ", ""); - row_spans.push(RtSpan::styled(cont_gutter, style_gutter())); + row_spans.push(RtSpan::styled(cont_gutter, gutter_style)); } row_spans.extend(chunk); - lines.push(RtLine::from(row_spans)); + lines.push(RtLine::from(row_spans).style(line_bg)); } lines @@ -703,20 +861,171 @@ pub(crate) fn line_number_width(max_line_number: usize) -> usize { } } -fn style_gutter() -> Style { - Style::default().add_modifier(Modifier::DIM) +/// Testable helper: picks `DiffTheme` from an explicit background sample. +fn diff_theme_for_bg(bg: Option<(u8, u8, u8)>) -> DiffTheme { + if let Some(rgb) = bg + && is_light(rgb) + { + return DiffTheme::Light; + } + DiffTheme::Dark +} + +/// Probe the terminal's background and return the appropriate diff palette. +fn diff_theme() -> DiffTheme { + diff_theme_for_bg(default_bg()) +} + +fn diff_color_level() -> DiffColorLevel { + match stdout_color_level() { + StdoutColorLevel::TrueColor => DiffColorLevel::TrueColor, + StdoutColorLevel::Ansi256 => DiffColorLevel::Ansi256, + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => DiffColorLevel::Ansi16, + } +} + +// -- Style helpers ------------------------------------------------------------ +// +// Each diff line is composed of three visual regions, styled independently: +// +// ┌──────────┬──────┬──────────────────────────────────────────┐ +// │ gutter │ sign │ content │ +// │ (line #) │ +/- │ (plain or syntax-highlighted text) │ +// └──────────┴──────┴──────────────────────────────────────────┘ +// +// A fourth, full-width layer — `line_bg` — is applied via `RtLine::style()` +// so that the background tint extends from the leftmost column to the right +// edge of the terminal, including any padding beyond the content. +// +// On dark terminals, the sign and content share one style (colored fg + tinted +// bg), and the gutter is simply dimmed. On light terminals, sign and content +// are split: the sign gets only a colored foreground (no bg, so the line bg +// shows through), while content relies on the line bg alone; the gutter gets +// an opaque, more-saturated background so line numbers stay readable against +// the pastel line tint. + +/// Full-width background applied to the `RtLine` itself (not individual spans). +/// Context lines intentionally leave the background unset so the terminal +/// default shows through. +fn style_line_bg_for(kind: DiffLineType, theme: DiffTheme, color_level: DiffColorLevel) -> Style { + match kind { + DiffLineType::Insert => Style::default().bg(add_line_bg(theme, color_level)), + DiffLineType::Delete => Style::default().bg(del_line_bg(theme, color_level)), + DiffLineType::Context => Style::default(), + } } fn style_context() -> Style { Style::default() } -fn style_add() -> Style { - Style::default().fg(Color::Green) +fn add_line_bg(theme: DiffTheme, color_level: DiffColorLevel) -> Color { + match (theme, color_level) { + (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb_color(DARK_TC_ADD_LINE_BG_RGB), + (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed_color(DARK_256_ADD_LINE_BG_IDX), + (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Green, + (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_ADD_LINE_BG_RGB), + (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed_color(LIGHT_256_ADD_LINE_BG_IDX), + (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::LightGreen, + } } -fn style_del() -> Style { - Style::default().fg(Color::Red) +fn del_line_bg(theme: DiffTheme, color_level: DiffColorLevel) -> Color { + match (theme, color_level) { + (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb_color(DARK_TC_DEL_LINE_BG_RGB), + (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed_color(DARK_256_DEL_LINE_BG_IDX), + (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Red, + (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_DEL_LINE_BG_RGB), + (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed_color(LIGHT_256_DEL_LINE_BG_IDX), + (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::LightRed, + } +} + +fn light_gutter_fg(color_level: DiffColorLevel) -> Color { + match color_level { + DiffColorLevel::TrueColor => rgb_color(LIGHT_TC_GUTTER_FG_RGB), + DiffColorLevel::Ansi256 => indexed_color(LIGHT_256_GUTTER_FG_IDX), + DiffColorLevel::Ansi16 => Color::Black, + } +} + +fn light_add_num_bg(color_level: DiffColorLevel) -> Color { + match color_level { + DiffColorLevel::TrueColor => rgb_color(LIGHT_TC_ADD_NUM_BG_RGB), + DiffColorLevel::Ansi256 => indexed_color(LIGHT_256_ADD_NUM_BG_IDX), + DiffColorLevel::Ansi16 => Color::Green, + } +} + +fn light_del_num_bg(color_level: DiffColorLevel) -> Color { + match color_level { + DiffColorLevel::TrueColor => rgb_color(LIGHT_TC_DEL_NUM_BG_RGB), + DiffColorLevel::Ansi256 => indexed_color(LIGHT_256_DEL_NUM_BG_IDX), + DiffColorLevel::Ansi16 => Color::Red, + } +} + +/// Line-number gutter style. On light backgrounds the gutter has an opaque +/// tinted background so numbers contrast against the pastel line fill. On +/// dark backgrounds a simple `DIM` modifier is sufficient. +fn style_gutter_for(kind: DiffLineType, theme: DiffTheme, color_level: DiffColorLevel) -> Style { + match (theme, kind) { + (DiffTheme::Light, DiffLineType::Insert) => Style::default() + .fg(light_gutter_fg(color_level)) + .bg(light_add_num_bg(color_level)), + (DiffTheme::Light, DiffLineType::Delete) => Style::default() + .fg(light_gutter_fg(color_level)) + .bg(light_del_num_bg(color_level)), + _ => style_gutter_dim(), + } +} + +/// Sign character (`+`) for insert lines. On dark terminals it inherits the +/// full content style (green fg + tinted bg). On light terminals it uses only +/// a green foreground and lets the line-level bg show through. +fn style_sign_add(theme: DiffTheme, color_level: DiffColorLevel) -> Style { + match theme { + DiffTheme::Light => Style::default().fg(Color::Green), + DiffTheme::Dark => style_add(theme, color_level), + } +} + +/// Sign character (`-`) for delete lines. Mirror of [`style_sign_add`]. +fn style_sign_del(theme: DiffTheme, color_level: DiffColorLevel) -> Style { + match theme { + DiffTheme::Light => Style::default().fg(Color::Red), + DiffTheme::Dark => style_del(theme, color_level), + } +} + +/// Content style for insert lines (plain, non-syntax-highlighted text). +fn style_add(theme: DiffTheme, color_level: DiffColorLevel) -> Style { + match (theme, color_level) { + (DiffTheme::Dark, DiffColorLevel::Ansi16) => Style::default() + .fg(Color::Black) + .bg(add_line_bg(theme, color_level)), + (DiffTheme::Light, _) => Style::default().bg(add_line_bg(theme, color_level)), + (DiffTheme::Dark, _) => Style::default() + .fg(Color::Green) + .bg(add_line_bg(theme, color_level)), + } +} + +/// Content style for delete lines (plain, non-syntax-highlighted text). +fn style_del(theme: DiffTheme, color_level: DiffColorLevel) -> Style { + match (theme, color_level) { + (DiffTheme::Dark, DiffColorLevel::Ansi16) => Style::default() + .fg(Color::Black) + .bg(del_line_bg(theme, color_level)), + (DiffTheme::Light, _) => Style::default().bg(del_line_bg(theme, color_level)), + (DiffTheme::Dark, _) => Style::default() + .fg(Color::Red) + .bg(del_line_bg(theme, color_level)), + } +} + +fn style_gutter_dim() -> Style { + Style::default().add_modifier(Modifier::DIM) } #[cfg(test)] @@ -728,6 +1037,35 @@ mod tests { use ratatui::backend::TestBackend; use ratatui::text::Text; use ratatui::widgets::Paragraph; + + #[test] + fn dark_ansi16_add_style_has_contrast() { + let style = style_add(DiffTheme::Dark, DiffColorLevel::Ansi16); + assert_eq!(style.fg, Some(Color::Black)); + assert_eq!(style.bg, Some(Color::Green)); + assert_ne!(style.fg, style.bg); + } + + #[test] + fn dark_ansi16_del_style_has_contrast() { + let style = style_del(DiffTheme::Dark, DiffColorLevel::Ansi16); + assert_eq!(style.fg, Some(Color::Black)); + assert_eq!(style.bg, Some(Color::Red)); + assert_ne!(style.fg, style.bg); + } + + #[test] + fn dark_ansi16_sign_styles_have_contrast() { + let add_sign = style_sign_add(DiffTheme::Dark, DiffColorLevel::Ansi16); + assert_eq!(add_sign.fg, Some(Color::Black)); + assert_eq!(add_sign.bg, Some(Color::Green)); + assert_ne!(add_sign.fg, add_sign.bg); + + let del_sign = style_sign_del(DiffTheme::Dark, DiffColorLevel::Ansi16); + assert_eq!(del_sign.fg, Some(Color::Black)); + assert_eq!(del_sign.bg, Some(Color::Red)); + assert_ne!(del_sign.fg, del_sign.bg); + } use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; fn diff_summary_for_tests(changes: &HashMap) -> Vec> { @@ -1128,6 +1466,148 @@ mod tests { snapshot_diff_gallery("diff_gallery_120x40", 120, 40); } + #[test] + fn truecolor_dark_theme_uses_configured_backgrounds() { + assert_eq!( + style_line_bg_for( + DiffLineType::Insert, + DiffTheme::Dark, + DiffColorLevel::TrueColor + ), + Style::default().bg(rgb_color(DARK_TC_ADD_LINE_BG_RGB)) + ); + assert_eq!( + style_line_bg_for( + DiffLineType::Delete, + DiffTheme::Dark, + DiffColorLevel::TrueColor + ), + Style::default().bg(rgb_color(DARK_TC_DEL_LINE_BG_RGB)) + ); + assert_eq!( + style_gutter_for( + DiffLineType::Insert, + DiffTheme::Dark, + DiffColorLevel::TrueColor + ), + style_gutter_dim() + ); + assert_eq!( + style_gutter_for( + DiffLineType::Delete, + DiffTheme::Dark, + DiffColorLevel::TrueColor + ), + style_gutter_dim() + ); + } + + #[test] + fn ansi256_dark_theme_uses_distinct_add_and_delete_backgrounds() { + assert_eq!( + style_line_bg_for( + DiffLineType::Insert, + DiffTheme::Dark, + DiffColorLevel::Ansi256 + ), + Style::default().bg(indexed_color(DARK_256_ADD_LINE_BG_IDX)) + ); + assert_eq!( + style_line_bg_for( + DiffLineType::Delete, + DiffTheme::Dark, + DiffColorLevel::Ansi256 + ), + Style::default().bg(indexed_color(DARK_256_DEL_LINE_BG_IDX)) + ); + assert_ne!( + style_line_bg_for( + DiffLineType::Insert, + DiffTheme::Dark, + DiffColorLevel::Ansi256 + ), + style_line_bg_for( + DiffLineType::Delete, + DiffTheme::Dark, + DiffColorLevel::Ansi256 + ), + "256-color mode should keep add/delete backgrounds distinct" + ); + } + + #[test] + fn light_truecolor_theme_uses_readable_gutter_and_line_backgrounds() { + assert_eq!( + style_line_bg_for( + DiffLineType::Insert, + DiffTheme::Light, + DiffColorLevel::TrueColor + ), + Style::default().bg(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB)) + ); + assert_eq!( + style_line_bg_for( + DiffLineType::Delete, + DiffTheme::Light, + DiffColorLevel::TrueColor + ), + Style::default().bg(rgb_color(LIGHT_TC_DEL_LINE_BG_RGB)) + ); + assert_eq!( + style_gutter_for( + DiffLineType::Insert, + DiffTheme::Light, + DiffColorLevel::TrueColor + ), + Style::default() + .fg(rgb_color(LIGHT_TC_GUTTER_FG_RGB)) + .bg(rgb_color(LIGHT_TC_ADD_NUM_BG_RGB)) + ); + assert_eq!( + style_gutter_for( + DiffLineType::Delete, + DiffTheme::Light, + DiffColorLevel::TrueColor + ), + Style::default() + .fg(rgb_color(LIGHT_TC_GUTTER_FG_RGB)) + .bg(rgb_color(LIGHT_TC_DEL_NUM_BG_RGB)) + ); + } + + #[test] + fn light_theme_wrapped_lines_keep_number_gutter_contrast() { + let lines = push_wrapped_diff_line_inner_with_theme_and_color_level( + 12, + DiffLineType::Insert, + "abcdefghij", + 8, + line_number_width(12), + None, + DiffTheme::Light, + DiffColorLevel::TrueColor, + ); + + assert!( + lines.len() > 1, + "expected wrapped output for gutter style verification" + ); + assert_eq!( + lines[0].spans[0].style, + Style::default() + .fg(rgb_color(LIGHT_TC_GUTTER_FG_RGB)) + .bg(rgb_color(LIGHT_TC_ADD_NUM_BG_RGB)) + ); + assert_eq!( + lines[1].spans[0].style, + Style::default() + .fg(rgb_color(LIGHT_TC_GUTTER_FG_RGB)) + .bg(rgb_color(LIGHT_TC_ADD_NUM_BG_RGB)) + ); + assert_eq!(lines[0].style.bg, Some(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB))); + assert_eq!(lines[1].style.bg, Some(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB))); + } + #[test] fn add_diff_uses_path_extension_for_highlighting() { let mut changes: HashMap = HashMap::new(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 76a7a853c9..e5971ba389 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -65,6 +65,7 @@ mod bottom_pane; mod chatwidget; mod cli; mod clipboard_paste; +mod clipboard_text; mod collaboration_modes; mod color; pub mod custom_terminal; @@ -120,6 +121,7 @@ mod voice; mod voice { use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; + use codex_protocol::protocol::RealtimeAudioFrame; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicBool; @@ -135,11 +137,17 @@ mod voice { pub(crate) struct RecordingMeterState; + pub(crate) struct RealtimeAudioPlayer; + impl VoiceCapture { pub fn start() -> Result { Err("voice input is unavailable in this build".to_string()) } + pub fn start_realtime(_tx: AppEventSender) -> Result { + Err("voice input is unavailable in this build".to_string()) + } + pub fn stop(self) -> Result { Err("voice input is unavailable in this build".to_string()) } @@ -175,6 +183,18 @@ mod voice { } } + impl RealtimeAudioPlayer { + pub(crate) fn start() -> Result { + Err("voice output is unavailable in this build".to_string()) + } + + pub(crate) fn enqueue_frame(&self, _frame: &RealtimeAudioFrame) -> Result<(), String> { + Err("voice output is unavailable in this build".to_string()) + } + + pub(crate) fn clear(&self) {} + } + pub fn transcribe_async( id: String, _audio: RecordedAudio, @@ -196,15 +216,13 @@ use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; pub use cli::Cli; +use codex_arg0::Arg0DispatchPaths; pub use markdown_render::render_markdown_text; pub use public_widgets::composer_input::ComposerAction; pub use public_widgets::composer_input::ComposerInput; // (tests access modules directly within the crate) -pub async fn run_main( - mut cli: Cli, - codex_linux_sandbox_exe: Option, -) -> std::io::Result { +pub async fn run_main(mut cli: Cli, arg0_paths: Arg0DispatchPaths) -> std::io::Result { let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -353,7 +371,8 @@ pub async fn run_main( cwd, model_provider: model_provider_override.clone(), config_profile: cli.config_profile.clone(), - codex_linux_sandbox_exe, + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), show_raw_agent_reasoning: cli.oss.then_some(true), additional_writable_roots: additional_dirs, ..Default::default() diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index b7a27576e8..5ee9ce5a47 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -1,4 +1,5 @@ use clap::Parser; +use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_tui::Cli; use codex_tui::run_main; @@ -14,14 +15,14 @@ struct TopCli { } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let top_cli = TopCli::parse(); let mut inner = top_cli.inner; inner .config_overrides .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); - let exit_info = run_main(inner, codex_linux_sandbox_exe).await?; + let exit_info = run_main(inner, arg0_paths).await?; let token_usage = exit_info.token_usage; if !token_usage.is_zero() { println!( diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e4ee2dc763..2799c80b24 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -33,6 +33,7 @@ pub enum SlashCommand { Agent, // Undo, Diff, + Copy, Mention, Status, DebugConfig, @@ -49,6 +50,7 @@ pub enum SlashCommand { Clean, Clear, Personality, + Realtime, TestApproval, // Debugging commands. #[strum(serialize = "debug-m-drop")] @@ -73,6 +75,7 @@ impl SlashCommand { // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", + SlashCommand::Copy => "copy the latest Codex output to your clipboard", SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", @@ -85,6 +88,7 @@ impl SlashCommand { SlashCommand::MemoryUpdate => "DO NOT USE", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Personality => "choose a communication style for Codex", + SlashCommand::Realtime => "toggle realtime voice mode (experimental)", SlashCommand::Plan => "switch to Plan mode", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent => "switch the active agent thread", @@ -143,6 +147,7 @@ impl SlashCommand { | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, SlashCommand::Diff + | SlashCommand::Copy | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills @@ -157,6 +162,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Realtime => true, SlashCommand::Collab => true, SlashCommand::Agent => true, SlashCommand::Statusline => false, @@ -167,6 +173,7 @@ impl SlashCommand { fn is_visible(self) -> bool { match self { SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), + SlashCommand::Copy => !cfg!(target_os = "android"), SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), _ => true, } diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 6349c007eb..304b8df087 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -9,26 +9,47 @@ fn bump_palette_version() { DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed); } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StdoutColorLevel { + TrueColor, + Ansi256, + Ansi16, + Unknown, +} + +pub fn stdout_color_level() -> StdoutColorLevel { + match supports_color::on_cached(supports_color::Stream::Stdout) { + Some(level) if level.has_16m => StdoutColorLevel::TrueColor, + Some(level) if level.has_256 => StdoutColorLevel::Ansi256, + Some(_) => StdoutColorLevel::Ansi16, + None => StdoutColorLevel::Unknown, + } +} + +#[allow(clippy::disallowed_methods)] +pub fn rgb_color((r, g, b): (u8, u8, u8)) -> Color { + Color::Rgb(r, g, b) +} + +#[allow(clippy::disallowed_methods)] +pub fn indexed_color(index: u8) -> Color { + Color::Indexed(index) +} + /// Returns the closest color to the target color that the terminal can display. pub fn best_color(target: (u8, u8, u8)) -> Color { - let Some(color_level) = supports_color::on_cached(supports_color::Stream::Stdout) else { - return Color::default(); - }; - if color_level.has_16m { - let (r, g, b) = target; - #[allow(clippy::disallowed_methods)] - Color::Rgb(r, g, b) - } else if color_level.has_256 + let color_level = stdout_color_level(); + if color_level == StdoutColorLevel::TrueColor { + rgb_color(target) + } else if color_level == StdoutColorLevel::Ansi256 && let Some((i, _)) = xterm_fixed_colors().min_by(|(_, a), (_, b)| { perceptual_distance(*a, target) .partial_cmp(&perceptual_distance(*b, target)) .unwrap_or(std::cmp::Ordering::Equal) }) { - #[allow(clippy::disallowed_methods)] - Color::Indexed(i as u8) + indexed_color(i as u8) } else { - #[allow(clippy::disallowed_methods)] Color::default() } } diff --git a/codex-rs/tui/src/theme_picker.rs b/codex-rs/tui/src/theme_picker.rs index 9205b5fb31..7a58711714 100644 --- a/codex-rs/tui/src/theme_picker.rs +++ b/codex-rs/tui/src/theme_picker.rs @@ -383,6 +383,7 @@ pub(crate) fn build_theme_picker_params( side_content_width: SideContentWidth::Half, side_content_min_width: WIDE_PREVIEW_MIN_WIDTH, stacked_side_content: Some(Box::new(ThemePreviewNarrowRenderable)), + preserve_side_content_bg: true, on_selection_changed, on_cancel, ..Default::default() diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index 6852fa6ff3..443ccc88a0 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -1,11 +1,15 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use base64::Engine; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core::default_client::get_codex_user_agent; use codex_login::AuthMode; use codex_login::CodexAuth; +use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RealtimeAudioFrame; use cpal::traits::DeviceTrait; use cpal::traits::HostTrait; use cpal::traits::StreamTrait; @@ -70,6 +74,37 @@ impl VoiceCapture { }) } + pub fn start_realtime(tx: AppEventSender) -> Result { + let (device, config) = select_input_device_and_config()?; + + let sample_rate = config.sample_rate().0; + let channels = config.channels(); + let data: Arc>> = Arc::new(Mutex::new(Vec::new())); + let stopped = Arc::new(AtomicBool::new(false)); + let last_peak = Arc::new(AtomicU16::new(0)); + + let stream = build_realtime_input_stream( + &device, + &config, + sample_rate, + channels, + tx, + last_peak.clone(), + )?; + stream + .play() + .map_err(|e| format!("failed to start input stream: {e}"))?; + + Ok(Self { + stream: Some(stream), + sample_rate, + channels, + data, + stopped, + last_peak, + }) + } + pub fn stop(mut self) -> Result { // Mark stopped so any metering task can exit cleanly. self.stopped.store(true, Ordering::SeqCst); @@ -292,6 +327,87 @@ fn build_input_stream( } } +fn build_realtime_input_stream( + device: &cpal::Device, + config: &cpal::SupportedStreamConfig, + sample_rate: u32, + channels: u16, + tx: AppEventSender, + last_peak: Arc, +) -> Result { + match config.sample_format() { + cpal::SampleFormat::F32 => device + .build_input_stream( + &config.clone().into(), + move |input: &[f32], _| { + let peak = peak_f32(input); + last_peak.store(peak, Ordering::Relaxed); + let samples = input.iter().copied().map(f32_to_i16).collect::>(); + send_realtime_audio_chunk(&tx, samples, sample_rate, channels); + }, + move |err| error!("audio input error: {err}"), + None, + ) + .map_err(|e| format!("failed to build input stream: {e}")), + cpal::SampleFormat::I16 => device + .build_input_stream( + &config.clone().into(), + move |input: &[i16], _| { + let peak = peak_i16(input); + last_peak.store(peak, Ordering::Relaxed); + send_realtime_audio_chunk(&tx, input.to_vec(), sample_rate, channels); + }, + move |err| error!("audio input error: {err}"), + None, + ) + .map_err(|e| format!("failed to build input stream: {e}")), + cpal::SampleFormat::U16 => device + .build_input_stream( + &config.clone().into(), + move |input: &[u16], _| { + let mut samples = Vec::with_capacity(input.len()); + let peak = convert_u16_to_i16_and_peak(input, &mut samples); + last_peak.store(peak, Ordering::Relaxed); + send_realtime_audio_chunk(&tx, samples, sample_rate, channels); + }, + move |err| error!("audio input error: {err}"), + None, + ) + .map_err(|e| format!("failed to build input stream: {e}")), + _ => Err("unsupported input sample format".to_string()), + } +} + +fn send_realtime_audio_chunk( + tx: &AppEventSender, + samples: Vec, + sample_rate: u32, + channels: u16, +) { + if samples.is_empty() || sample_rate == 0 || channels == 0 { + return; + } + + let mut bytes = Vec::with_capacity(samples.len() * 2); + for sample in &samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + let samples_per_channel = (samples.len() / usize::from(channels)) as u32; + + tx.send(AppEvent::CodexOp(Op::RealtimeConversationAudio( + ConversationAudioParams { + frame: RealtimeAudioFrame { + data: encoded, + sample_rate, + num_channels: channels, + samples_per_channel: Some(samples_per_channel), + }, + }, + ))); +} + #[inline] fn f32_abs_to_u16(x: f32) -> u16 { let peak_u = (x.abs().min(1.0) * i16::MAX as f32) as i32; @@ -338,6 +454,207 @@ fn convert_u16_to_i16_and_peak(input: &[u16], out: &mut Vec) -> u16 { peak as u16 } +// ------------------------- +// Realtime audio playback helpers +// ------------------------- + +pub(crate) struct RealtimeAudioPlayer { + _stream: cpal::Stream, + queue: Arc>>, + output_sample_rate: u32, + output_channels: u16, +} + +impl RealtimeAudioPlayer { + pub(crate) fn start() -> Result { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| "no output audio device available".to_string())?; + let config = device + .default_output_config() + .map_err(|e| format!("failed to get default output config: {e}"))?; + let output_sample_rate = config.sample_rate().0; + let output_channels = config.channels(); + let queue = Arc::new(Mutex::new(VecDeque::new())); + let stream = build_output_stream(&device, &config, Arc::clone(&queue))?; + stream + .play() + .map_err(|e| format!("failed to start output stream: {e}"))?; + Ok(Self { + _stream: stream, + queue, + output_sample_rate, + output_channels, + }) + } + + pub(crate) fn enqueue_frame(&self, frame: &RealtimeAudioFrame) -> Result<(), String> { + if frame.num_channels == 0 || frame.sample_rate == 0 { + return Err("invalid realtime audio frame format".to_string()); + } + let raw_bytes = base64::engine::general_purpose::STANDARD + .decode(&frame.data) + .map_err(|e| format!("failed to decode realtime audio: {e}"))?; + if raw_bytes.len() % 2 != 0 { + return Err("realtime audio frame had odd byte length".to_string()); + } + let mut pcm = Vec::with_capacity(raw_bytes.len() / 2); + for pair in raw_bytes.chunks_exact(2) { + pcm.push(i16::from_le_bytes([pair[0], pair[1]])); + } + let converted = convert_pcm16_for_output( + &pcm, + frame.sample_rate, + frame.num_channels, + self.output_sample_rate, + self.output_channels, + ); + if converted.is_empty() { + return Ok(()); + } + let mut guard = self + .queue + .lock() + .map_err(|_| "failed to lock output audio queue".to_string())?; + // TODO(aibrahim): Cap or trim this queue if we observe producer bursts outrunning playback. + guard.extend(converted); + Ok(()) + } + + pub(crate) fn clear(&self) { + if let Ok(mut guard) = self.queue.lock() { + guard.clear(); + } + } +} + +fn build_output_stream( + device: &cpal::Device, + config: &cpal::SupportedStreamConfig, + queue: Arc>>, +) -> Result { + let config_any: cpal::StreamConfig = config.clone().into(); + match config.sample_format() { + cpal::SampleFormat::F32 => device + .build_output_stream( + &config_any, + move |output: &mut [f32], _| fill_output_f32(output, &queue), + move |err| error!("audio output error: {err}"), + None, + ) + .map_err(|e| format!("failed to build f32 output stream: {e}")), + cpal::SampleFormat::I16 => device + .build_output_stream( + &config_any, + move |output: &mut [i16], _| fill_output_i16(output, &queue), + move |err| error!("audio output error: {err}"), + None, + ) + .map_err(|e| format!("failed to build i16 output stream: {e}")), + cpal::SampleFormat::U16 => device + .build_output_stream( + &config_any, + move |output: &mut [u16], _| fill_output_u16(output, &queue), + move |err| error!("audio output error: {err}"), + None, + ) + .map_err(|e| format!("failed to build u16 output stream: {e}")), + other => Err(format!("unsupported output sample format: {other:?}")), + } +} + +fn fill_output_i16(output: &mut [i16], queue: &Arc>>) { + if let Ok(mut guard) = queue.lock() { + for sample in output { + *sample = guard.pop_front().unwrap_or(0); + } + return; + } + output.fill(0); +} + +fn fill_output_f32(output: &mut [f32], queue: &Arc>>) { + if let Ok(mut guard) = queue.lock() { + for sample in output { + let v = guard.pop_front().unwrap_or(0); + *sample = (v as f32) / (i16::MAX as f32); + } + return; + } + output.fill(0.0); +} + +fn fill_output_u16(output: &mut [u16], queue: &Arc>>) { + if let Ok(mut guard) = queue.lock() { + for sample in output { + let v = guard.pop_front().unwrap_or(0); + *sample = (v as i32 + 32768).clamp(0, u16::MAX as i32) as u16; + } + return; + } + output.fill(32768); +} + +fn convert_pcm16_for_output( + input: &[i16], + input_sample_rate: u32, + input_channels: u16, + output_sample_rate: u32, + output_channels: u16, +) -> Vec { + if input.is_empty() || input_channels == 0 || output_channels == 0 { + return Vec::new(); + } + + let in_channels = input_channels as usize; + let out_channels = output_channels as usize; + let in_frames = input.len() / in_channels; + if in_frames == 0 { + return Vec::new(); + } + + let out_frames = if input_sample_rate == output_sample_rate { + in_frames + } else { + (((in_frames as u64) * (output_sample_rate as u64)) / (input_sample_rate as u64)).max(1) + as usize + }; + + let mut out = Vec::with_capacity(out_frames.saturating_mul(out_channels)); + for out_frame_idx in 0..out_frames { + let src_frame_idx = if out_frames <= 1 || in_frames <= 1 { + 0 + } else { + ((out_frame_idx as u64) * ((in_frames - 1) as u64) / ((out_frames - 1) as u64)) as usize + }; + let src_start = src_frame_idx.saturating_mul(in_channels); + let src = &input[src_start..src_start + in_channels]; + match (in_channels, out_channels) { + (1, 1) => out.push(src[0]), + (1, n) => { + for _ in 0..n { + out.push(src[0]); + } + } + (n, 1) if n >= 2 => { + let sum: i32 = src.iter().map(|s| *s as i32).sum(); + out.push((sum / (n as i32)) as i16); + } + (n, m) if n == m => out.extend_from_slice(src), + (n, m) if n > m => out.extend_from_slice(&src[..m]), + (n, m) => { + out.extend_from_slice(src); + let last = *src.last().unwrap_or(&0); + for _ in n..m { + out.push(last); + } + } + } + } + out +} + // ------------------------- // Transcription helpers // ------------------------- diff --git a/codex-rs/utils/sleep-inhibitor/Cargo.toml b/codex-rs/utils/sleep-inhibitor/Cargo.toml index f691b191e1..888705a770 100644 --- a/codex-rs/utils/sleep-inhibitor/Cargo.toml +++ b/codex-rs/utils/sleep-inhibitor/Cargo.toml @@ -7,6 +7,19 @@ license.workspace = true [lints] workspace = true +[dependencies] +tracing = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" -tracing = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_System_Power", + "Win32_System_SystemServices", + "Win32_System_Threading", +] } diff --git a/codex-rs/utils/sleep-inhibitor/src/lib.rs b/codex-rs/utils/sleep-inhibitor/src/lib.rs index 810fb170a2..8c9c5a9ccd 100644 --- a/codex-rs/utils/sleep-inhibitor/src/lib.rs +++ b/codex-rs/utils/sleep-inhibitor/src/lib.rs @@ -1,17 +1,29 @@ //! Cross-platform helper for preventing idle sleep while a turn is running. //! -//! On macOS this uses native IOKit power assertions instead of spawning -//! `caffeinate`, so assertion lifecycle is tied directly to Rust object lifetime. +//! Platform-specific behavior: +//! - macOS: Uses native IOKit power assertions instead of spawning `caffeinate`. +//! - Linux: Spawns `systemd-inhibit` or `gnome-session-inhibit` while active. +//! - Windows: Uses `PowerCreateRequest` + `PowerSetRequest` with +//! `PowerRequestSystemRequired`. +//! - Other platforms: No-op backend. -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] mod dummy; +#[cfg(target_os = "linux")] +mod linux_inhibitor; #[cfg(target_os = "macos")] mod macos; +#[cfg(target_os = "windows")] +mod windows_inhibitor; -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] use dummy as imp; +#[cfg(target_os = "linux")] +use linux_inhibitor as imp; #[cfg(target_os = "macos")] use macos as imp; +#[cfg(target_os = "windows")] +use windows_inhibitor as imp; /// Keeps the machine awake while a turn is in progress when enabled. #[derive(Debug)] diff --git a/codex-rs/utils/sleep-inhibitor/src/linux_inhibitor.rs b/codex-rs/utils/sleep-inhibitor/src/linux_inhibitor.rs new file mode 100644 index 0000000000..e97d42b223 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/linux_inhibitor.rs @@ -0,0 +1,240 @@ +use std::os::unix::process::CommandExt; +use std::process::Child; +use std::process::Command; +use std::process::Stdio; +use tracing::warn; + +const ASSERTION_REASON: &str = "Codex is running an active turn"; +const APP_ID: &str = "codex"; +// Keep the blocker process alive "long enough" without needing restarts. +// This is `i32::MAX` seconds, which is accepted by common `sleep` implementations. +const BLOCKER_SLEEP_SECONDS: &str = "2147483647"; + +#[derive(Debug, Default)] +pub(crate) struct LinuxSleepInhibitor { + state: InhibitState, + preferred_backend: Option, + missing_backend_logged: bool, +} + +pub(crate) use LinuxSleepInhibitor as SleepInhibitor; + +#[derive(Debug, Default)] +enum InhibitState { + #[default] + Inactive, + Active { + backend: LinuxBackend, + child: Child, + }, +} + +#[derive(Debug, Clone, Copy)] +enum LinuxBackend { + SystemdInhibit, + GnomeSessionInhibit, +} + +impl LinuxSleepInhibitor { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn acquire(&mut self) { + if let InhibitState::Active { backend, child } = &mut self.state { + match child.try_wait() { + Ok(None) => return, + Ok(Some(status)) => { + warn!( + ?backend, + ?status, + "Linux sleep inhibitor backend exited unexpectedly; attempting fallback" + ); + } + Err(error) => { + warn!( + ?backend, + reason = %error, + "Failed to query Linux sleep inhibitor backend status; attempting restart" + ); + } + } + } + + self.state = InhibitState::Inactive; + let should_log_backend_failures = !self.missing_backend_logged; + let backends = match self.preferred_backend { + Some(LinuxBackend::SystemdInhibit) => [ + LinuxBackend::SystemdInhibit, + LinuxBackend::GnomeSessionInhibit, + ], + Some(LinuxBackend::GnomeSessionInhibit) => [ + LinuxBackend::GnomeSessionInhibit, + LinuxBackend::SystemdInhibit, + ], + None => [ + LinuxBackend::SystemdInhibit, + LinuxBackend::GnomeSessionInhibit, + ], + }; + + for backend in backends { + match spawn_backend(backend) { + Ok(mut child) => match child.try_wait() { + Ok(None) => { + self.state = InhibitState::Active { backend, child }; + self.preferred_backend = Some(backend); + self.missing_backend_logged = false; + return; + } + Ok(Some(status)) => { + if should_log_backend_failures { + warn!( + ?backend, + ?status, + "Linux sleep inhibitor backend exited immediately" + ); + } + } + Err(error) => { + if should_log_backend_failures { + warn!( + ?backend, + reason = %error, + "Failed to query Linux sleep inhibitor backend status after spawn" + ); + } + if let Err(kill_error) = child.kill() + && !child_exited(&kill_error) + { + warn!( + ?backend, + reason = %kill_error, + "Failed to stop Linux sleep inhibitor backend after status probe failure" + ); + } + if let Err(wait_error) = child.wait() + && !child_exited(&wait_error) + { + warn!( + ?backend, + reason = %wait_error, + "Failed to reap Linux sleep inhibitor backend after status probe failure" + ); + } + } + }, + Err(error) => { + if should_log_backend_failures && error.kind() != std::io::ErrorKind::NotFound { + warn!( + ?backend, + reason = %error, + "Failed to start Linux sleep inhibitor backend" + ); + } + } + } + } + + if should_log_backend_failures { + warn!("No Linux sleep inhibitor backend is available"); + self.missing_backend_logged = true; + } + } + + pub(crate) fn release(&mut self) { + match std::mem::take(&mut self.state) { + InhibitState::Inactive => {} + InhibitState::Active { backend, mut child } => { + if let Err(error) = child.kill() + && !child_exited(&error) + { + warn!(?backend, reason = %error, "Failed to stop Linux sleep inhibitor backend"); + } + if let Err(error) = child.wait() + && !child_exited(&error) + { + warn!(?backend, reason = %error, "Failed to reap Linux sleep inhibitor backend"); + } + } + } + } +} + +impl Drop for LinuxSleepInhibitor { + fn drop(&mut self) { + self.release(); + } +} + +fn spawn_backend(backend: LinuxBackend) -> Result { + // Ensure the helper receives SIGTERM when the original parent dies. + // `parent_pid` is captured before spawn and checked in `pre_exec` to avoid + // the fork/exec race where the parent exits before PDEATHSIG is armed. + // SAFETY: `getpid` has no preconditions and is safe to call here. + let parent_pid = unsafe { libc::getpid() }; + let mut command = match backend { + LinuxBackend::SystemdInhibit => { + let mut command = Command::new("systemd-inhibit"); + command.args([ + "--what=idle", + "--mode=block", + "--who", + APP_ID, + "--why", + ASSERTION_REASON, + "--", + "sleep", + BLOCKER_SLEEP_SECONDS, + ]); + command + } + LinuxBackend::GnomeSessionInhibit => { + let mut command = Command::new("gnome-session-inhibit"); + command.args([ + "--inhibit", + "idle", + "--reason", + ASSERTION_REASON, + "sleep", + BLOCKER_SLEEP_SECONDS, + ]); + command + } + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + // SAFETY: `pre_exec` must be registered before spawn. The closure only + // performs libc setup for the child process and returns an `io::Error` + // when parent-death signal setup fails. + unsafe { + command.pre_exec(move || { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::getppid() != parent_pid { + libc::raise(libc::SIGTERM); + } + Ok(()) + }); + } + + command.spawn() +} + +fn child_exited(error: &std::io::Error) -> bool { + matches!(error.kind(), std::io::ErrorKind::InvalidInput) +} + +#[cfg(test)] +mod tests { + use super::BLOCKER_SLEEP_SECONDS; + + #[test] + fn sleep_seconds_is_i32_max() { + assert_eq!(BLOCKER_SLEEP_SECONDS, format!("{}", i32::MAX)); + } +} diff --git a/codex-rs/utils/sleep-inhibitor/src/windows_inhibitor.rs b/codex-rs/utils/sleep-inhibitor/src/windows_inhibitor.rs new file mode 100644 index 0000000000..2cf039534d --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/windows_inhibitor.rs @@ -0,0 +1,119 @@ +use std::ffi::OsStr; +use std::iter::once; +use std::os::windows::ffi::OsStrExt; +use tracing::warn; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::System::Power::POWER_REQUEST_TYPE; +use windows_sys::Win32::System::Power::PowerClearRequest; +use windows_sys::Win32::System::Power::PowerCreateRequest; +use windows_sys::Win32::System::Power::PowerRequestSystemRequired; +use windows_sys::Win32::System::Power::PowerSetRequest; +use windows_sys::Win32::System::SystemServices::POWER_REQUEST_CONTEXT_VERSION; +use windows_sys::Win32::System::Threading::POWER_REQUEST_CONTEXT_SIMPLE_STRING; +use windows_sys::Win32::System::Threading::REASON_CONTEXT; +use windows_sys::Win32::System::Threading::REASON_CONTEXT_0; + +const ASSERTION_REASON: &str = "Codex is running an active turn"; + +#[derive(Debug, Default)] +pub(crate) struct WindowsSleepInhibitor { + request: Option, +} + +pub(crate) use WindowsSleepInhibitor as SleepInhibitor; + +impl WindowsSleepInhibitor { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn acquire(&mut self) { + if self.request.is_some() { + return; + } + + match PowerRequest::new_system_required(ASSERTION_REASON) { + Ok(request) => { + self.request = Some(request); + } + Err(error) => { + warn!( + reason = %error, + "Failed to acquire Windows sleep-prevention request" + ); + } + } + } + + pub(crate) fn release(&mut self) { + self.request = None; + } +} + +#[derive(Debug)] +struct PowerRequest { + handle: windows_sys::Win32::Foundation::HANDLE, + request_type: POWER_REQUEST_TYPE, +} + +impl PowerRequest { + fn new_system_required(reason: &str) -> Result { + let mut wide_reason: Vec = OsStr::new(reason).encode_wide().chain(once(0)).collect(); + let context = REASON_CONTEXT { + Version: POWER_REQUEST_CONTEXT_VERSION, + Flags: POWER_REQUEST_CONTEXT_SIMPLE_STRING, + Reason: REASON_CONTEXT_0 { + SimpleReasonString: wide_reason.as_mut_ptr(), + }, + }; + // SAFETY: `context` points to a valid `REASON_CONTEXT` for the duration + // of the call and Windows copies the relevant data before returning. + let handle = unsafe { PowerCreateRequest(&context) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + let error = std::io::Error::last_os_error(); + return Err(format!("PowerCreateRequest failed: {error}")); + } + + // Match macOS `PreventUserIdleSystemSleep`: prevent idle system sleep + // without forcing the display to stay on. + let request_type = PowerRequestSystemRequired; + // SAFETY: `handle` is a live power request handle and `request_type` is a + // valid power request enum value. + if unsafe { PowerSetRequest(handle, request_type) } == 0 { + let error = std::io::Error::last_os_error(); + // SAFETY: `handle` was returned by `PowerCreateRequest` and has not + // been closed yet on this error path. + let _ = unsafe { CloseHandle(handle) }; + return Err(format!("PowerSetRequest failed: {error}")); + } + + Ok(Self { + handle, + request_type, + }) + } +} + +impl Drop for PowerRequest { + fn drop(&mut self) { + // SAFETY: `self.handle` is the handle owned by this `PowerRequest`, and + // `self.request_type` is the request type that was set on acquire. + if unsafe { PowerClearRequest(self.handle, self.request_type) } == 0 { + let error = std::io::Error::last_os_error(); + warn!( + reason = %error, + "Failed to clear Windows sleep-prevention request" + ); + } + // SAFETY: `self.handle` is owned by this struct and closed exactly once + // in `Drop`. + if unsafe { CloseHandle(self.handle) } == 0 { + let error = std::io::Error::last_os_error(); + warn!( + reason = %error, + "Failed to close Windows sleep-prevention request handle" + ); + } + } +} diff --git a/docs/config.md b/docs/config.md index ed80e559c1..30665bb11b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,6 +28,12 @@ Codex can run a notification hook when the agent finishes a turn. See the config The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`. +## SQLite State DB + +Codex stores the SQLite-backed state DB under `sqlite_home` (config key) or the +`CODEX_SQLITE_HOME` environment variable. When unset, WorkspaceWrite sandbox +sessions default to a temp directory; other modes default to `CODEX_HOME`. + ## Notices Codex stores "do not show again" flags for some UI prompts under the `[notice]` table.