Merge c5b2e2af26 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-02-24 14:58:39 -08:00
committed by GitHub
84 changed files with 6612 additions and 472 deletions
+3
View File
@@ -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]]
+1
View File
@@ -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"
@@ -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<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
codex_linux_sandbox_exe: Option<PathBuf>,
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
single_client_mode: bool,
cli_overrides: Vec<(String, TomlValue)>,
@@ -361,7 +362,7 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) thread_manager: Arc<ThreadManager>,
pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) arg0_paths: Arg0DispatchPaths,
pub(crate) config: Arc<Config>,
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
@@ -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<Config, JSONRPCErrorError> {
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()),
},
)
+5 -5
View File
@@ -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<PathBuf>,
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<PathBuf>,
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,
+3 -2
View File
@@ -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,
+10 -4
View File
@@ -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<OutgoingMessageSender>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) arg0_paths: Arg0DispatchPaths,
pub(crate) config: Arc<Config>,
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(),
@@ -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
@@ -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);
@@ -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,
}
})
@@ -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<std::path::PathBuf> {
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<String> {
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<String> {
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::<TurnStartResponse>(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
"#
),
)
}
+44 -12
View File
@@ -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<PathBuf>,
pub main_execve_wrapper_exe: Option<PathBuf>,
}
/// 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<Arg0PathEntryGuard> {
@@ -124,33 +136,43 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
/// 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<PathBuf>`, 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<F, Fut>(main_fn: F) -> anyhow::Result<()>
where
F: FnOnce(Option<PathBuf>) -> Fut,
F: FnOnce(Arg0DispatchPaths) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
// 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<PathBuf> = 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<Arg0PathEntryGu
std::env::set_var("PATH", updated_path_env_var);
}
Ok(Arg0PathEntryGuard::new(temp_dir, lock_file))
let mut paths = Arg0DispatchPaths::default();
#[cfg(target_os = "linux")]
{
paths.codex_linux_sandbox_exe = Some(path.join(LINUX_SANDBOX_ARG0));
}
#[cfg(unix)]
{
paths.main_execve_wrapper_exe = Some(path.join(EXECVE_WRAPPER_ARG0));
}
Ok(Arg0PathEntryGuard::new(temp_dir, lock_file, paths))
}
fn janitor_cleanup(temp_root: &Path) -> std::io::Result<()> {
+18 -16
View File
@@ -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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>,
arg0_paths: Arg0DispatchPaths,
) -> std::io::Result<AppExitInfo> {
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<bool> {
+1
View File
@@ -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 }
+26
View File
@@ -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"
+5 -1
View File
@@ -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<String>,
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<usize>) -> 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
}
+1
View File
@@ -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;
+26 -22
View File
@@ -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;
+120 -2
View File
@@ -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<usize> = Some(6);
pub(crate) const DEFAULT_AGENT_MAX_SPAWN_DEPTH: Option<usize> = Some(2);
pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = 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<PathBuf> {
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<usize>,
/// Maximum depth for thread-spawned subagents.
pub agent_max_spawn_depth: Option<usize>,
/// Maximum runtime in seconds for agent job workers before they are failed.
pub agent_job_max_runtime_seconds: Option<u64>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<PathBuf>,
@@ -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<History>,
/// 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<AbsolutePathBuf>,
/// Directory where Codex writes log files, for example `codex-tui.log`.
/// Defaults to `$CODEX_HOME/log`.
pub log_dir: Option<AbsolutePathBuf>,
@@ -1295,11 +1340,16 @@ pub struct AgentsToml {
/// When unset, no limit is enforced.
#[schemars(range(min = 1))]
pub max_threads: Option<usize>,
/// Maximum depth for thread-spawned subagents.
#[schemars(range(min = 1))]
pub max_spawn_depth: Option<usize>,
/// Maximum nesting depth allowed for spawned agent threads.
/// Root sessions start at depth 0.
#[schemars(range(min = 1))]
pub max_depth: Option<i32>,
/// Default maximum runtime in seconds for agent job workers.
#[schemars(range(min = 1))]
pub job_max_runtime_seconds: Option<u64>,
/// User-defined role declarations keyed by role name.
///
@@ -1492,6 +1542,7 @@ pub struct ConfigOverrides {
pub model_provider: Option<String>,
pub config_profile: Option<String>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub main_execve_wrapper_exe: Option<PathBuf>,
pub js_repl_node_path: Option<PathBuf>,
pub js_repl_node_module_dirs: Option<Vec<PathBuf>>,
pub zsh_path: Option<PathBuf>,
@@ -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,
+8
View File
@@ -62,6 +62,7 @@ pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000;
#[derive(Debug)]
pub struct ExecParams {
pub command: Vec<String>,
pub original_command: String,
pub cwd: PathBuf,
pub expiration: ExecExpiration,
pub env: HashMap<String, String>,
@@ -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<String, String> = 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),
+13 -1
View File
@@ -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.",
+74 -1
View File
@@ -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<ModelInfo> {
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;
+122 -37
View File
@@ -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<Option<String
/// Discover the list of AGENTS.md files using the same search rules as
/// `read_project_docs`, but return the file paths instead of concatenated
/// contents. The list is ordered from repository root to the current working
/// contents. The list is ordered from project root to the current working
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
/// is zero, returns an empty list.
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
@@ -187,43 +195,62 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
dir = canon;
}
// Build chain from cwd upwards and detect git root.
let mut chain: Vec<PathBuf> = vec![dir.clone()];
let mut git_root: Option<PathBuf> = 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<PathBuf> = if let Some(root) = git_root {
let mut dirs: Vec<PathBuf> = 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<PathBuf> = 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<PathBuf> = 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() {
+35 -17
View File
@@ -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(),
})
}
}
+141 -11
View File
@@ -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<SkillMetadata>,
@@ -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<SkillMetadata> {
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<SkillMetadata> {
detect_skill_script_run(outcome, command, workdir)
}
fn tokenize_command(command: &str) -> Vec<String> {
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<String> {
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);
}
}
+2
View File
@@ -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;
+2
View File
@@ -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<PathBuf>,
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) main_execve_wrapper_exe: Option<PathBuf>,
pub(crate) analytics_events_client: AnalyticsEventsClient,
pub(crate) hooks: Hooks,
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
+5 -5
View File
@@ -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<StateDbHandle> {
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.
+7
View File
@@ -16,6 +16,12 @@ use tokio::sync::Mutex;
pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ToolCallSource {
Direct,
JsRepl,
}
#[derive(Clone)]
pub struct ToolInvocation {
pub session: Arc<Session>,
@@ -24,6 +30,7 @@ pub struct ToolInvocation {
pub call_id: String,
pub tool_name: String,
pub payload: ToolPayload,
pub source: ToolCallSource,
}
#[derive(Clone, Debug)]
File diff suppressed because it is too large Load Diff
@@ -86,6 +86,7 @@ impl ToolHandler for ApplyPatchHandler {
call_id,
tool_name,
payload,
..
} = invocation;
let patch_input = match payload {
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod agent_jobs;
pub mod apply_patch;
mod dynamic;
mod grep_files;
@@ -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,
});
+29 -7
View File
@@ -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(),
&params.command,
params.workdir.as_deref(),
)
.await;
let prefix_rule = params.prefix_rule.clone();
let exec_params = Self::to_exec_params(
&params,
@@ -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(
+23 -1
View File
@@ -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 } => {
+3 -6
View File
@@ -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<ConfiguredToolSpec>,
@@ -179,6 +175,7 @@ impl ToolRouter {
call_id,
tool_name,
payload,
source,
};
match self.registry.dispatch(invocation).await {
@@ -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<PathBuf> {
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,
+257 -1
View File
@@ -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<String>,
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<WebSearchMode>,
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::<Vec<_>>();
@@ -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,
+1 -40
View File
@@ -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
`<memory_citation>` block as the VERY LAST content of the final reply.
Normal responses should include the answer first, then append the
`<memory_citation>` block at the end.
- Use this exact structure for programmatic parsing:
<memory_citation>
<citation_entries>
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]
</citation_entries>
<thread_ids>
019c6e27-e55b-73d1-87d8-4e01f1f75043
019c7714-3b77-74d1-9866-e1f484aae2ab
</thread_ids>
</memory_citation>
- `citation_entries` is for rendering:
- one citation entry per line
- format: `<file>:<line_start>-<line_end>|note=[<how memory was used>]`
- 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 `<thread_ids>` 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.
deep repo exploration.
+8
View File
@@ -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![];
+424
View File
@@ -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<AtomicUsize>,
}
impl StopAfterFirstResponder {
fn new(spawn_args_json: String, worker_calls: Arc<AtomicUsize>) -> 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<u8> {
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<String> {
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<String> {
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(())
}
+1
View File
@@ -33,6 +33,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
let params = ExecParams {
command: cmd.iter().map(ToString::to_string).collect(),
original_command: cmd.iter().map(ToString::to_string).collect(),
cwd: tmp.path().to_path_buf(),
expiration: 1000.into(),
env: HashMap::new(),
+1
View File
@@ -56,6 +56,7 @@ pub static CODEX_ALIASES_TEMP_DIR: TestCodexAliasesGuard = unsafe {
#[cfg(not(target_os = "windows"))]
mod abort_tasks;
mod agent_jobs;
mod agent_websocket;
mod apply_patch_cli;
#[cfg(not(target_os = "windows"))]
@@ -195,6 +195,65 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> 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(()));
+10 -4
View File
@@ -52,10 +52,16 @@ async fn wait_for_snapshot(codex_home: &Path) -> Result<PathBuf> {
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 {
+275 -44
View File
@@ -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<PathBuf> {
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<String> {
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<String> {
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(())
}
+3 -3
View File
@@ -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;
+4
View File
@@ -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",
@@ -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<codex_protocol::protocol::TokenUsageInfo>,
final_message: Option<String>,
last_proposed_plan: Option<String>,
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<PathBuf>,
) -> 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<u64>,
}
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<AgentJobProgressMessage> {
let payload = message.strip_prefix("agent_job_progress:")?;
serde_json::from_str::<AgentJobProgressMessage>(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::<String>();
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::<usize>().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<usize>,
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(" "))
}
+66 -6
View File
@@ -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<codex_core::CodexThread>,
event: Event,
suppress_output: bool,
}
pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> any
let mut event_processor: Box<dyn EventProcessor> = 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<PathBuf>) -> any
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ThreadEventEnvelope>();
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<PathBuf>) -> 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<PathBuf>) -> 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<codex_core::CodexThread>,
tx: tokio::sync::mpsc::UnboundedSender<ThreadEventEnvelope>,
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,
+3 -2
View File
@@ -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(())
})
}
@@ -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.
+1 -1
View File
@@ -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(),
+4 -2
View File
@@ -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<PathBuf>,
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,
+3 -3
View File
@@ -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<ClientRequest, Value, ClientNotification>;
pub async fn run_main(
codex_linux_sandbox_exe: Option<PathBuf>,
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 {
+3 -2
View File
@@ -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(())
})
}
+5 -8
View File
@@ -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<OutgoingMessageSender>,
initialized: bool,
codex_linux_sandbox_exe: Option<PathBuf>,
arg0_paths: Arg0DispatchPaths,
thread_manager: Arc<ThreadManager>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ThreadId>>>,
}
@@ -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<PathBuf>,
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
) -> 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::<CodexToolCallParam>(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 {
@@ -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);
@@ -0,0 +1,2 @@
ALTER TABLE agent_jobs
ADD COLUMN max_runtime_seconds INTEGER;
+10
View File
@@ -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;
+256
View File
@@ -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<Self> {
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<Self> {
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<u64>,
// TODO(jif-oai): Convert to JSON Schema and enforce structured outputs.
pub output_schema_json: Option<Value>,
pub input_headers: Vec<String>,
pub input_csv_path: String,
pub output_csv_path: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AgentJobItem {
pub job_id: String,
pub item_id: String,
pub row_index: i64,
pub source_id: Option<String>,
pub row_json: Value,
pub status: AgentJobItemStatus,
pub assigned_thread_id: Option<String>,
pub attempt_count: i64,
pub result_json: Option<Value>,
pub last_error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub reported_at: Option<DateTime<Utc>>,
}
#[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<u64>,
pub output_schema_json: Option<Value>,
pub input_headers: Vec<String>,
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<String>,
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<i64>,
pub(crate) output_schema_json: Option<String>,
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<i64>,
pub(crate) completed_at: Option<i64>,
pub(crate) last_error: Option<String>,
}
impl TryFrom<AgentJobRow> for AgentJob {
type Error = anyhow::Error;
fn try_from(value: AgentJobRow) -> Result<Self, Self::Error> {
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<String>,
pub(crate) row_json: String,
pub(crate) status: String,
pub(crate) assigned_thread_id: Option<String>,
pub(crate) attempt_count: i64,
pub(crate) result_json: Option<String>,
pub(crate) last_error: Option<String>,
pub(crate) created_at: i64,
pub(crate) updated_at: i64,
pub(crate) completed_at: Option<i64>,
pub(crate) reported_at: Option<i64>,
}
impl TryFrom<AgentJobItemRow> for AgentJobItem {
type Error = anyhow::Error;
fn try_from(value: AgentJobItemRow) -> Result<Self, Self::Error> {
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<Utc>> {
DateTime::<Utc>::from_timestamp(secs, 0)
.ok_or_else(|| anyhow::anyhow!("invalid unix timestamp: {secs}"))
}
+10
View File
@@ -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;
+567
View File
@@ -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<AgentJob> {
let now = Utc::now().timestamp();
let input_headers_json = serde_json::to_string(&params.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<Option<AgentJob>> {
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<AgentJobItemStatus>,
limit: Option<usize>,
) -> anyhow::Result<Vec<AgentJobItem>> {
let mut builder = QueryBuilder::<Sqlite>::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::<AgentJobItemRow>()
.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<Option<AgentJobItem>> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<AgentJobProgress> {
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<i64> = row.try_get("pending_items")?;
let running_items: Option<i64> = row.try_get("running_items")?;
let completed_items: Option<i64> = row.try_get("completed_items")?;
let failed_items: Option<i64> = 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#"
+147 -3
View File
@@ -389,6 +389,7 @@ pub(crate) struct ChatComposer {
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
windows_degraded_sandbox_active: bool,
status_line_value: Option<Line<'static>>,
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::<AppEvent>();
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).
@@ -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,
},
);
@@ -163,6 +163,10 @@ pub(crate) struct SelectionViewParams {
/// When absent, `side_content` is reused.
pub stacked_side_content: Option<Box<dyn Renderable>>,
/// 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<Box<dyn Renderable>>,
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
+12
View File
@@ -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();
+16 -2
View File
@@ -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<SlashCommand> {
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
);
}
}
+140 -5
View File
@@ -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<StreamController>,
// Stream lifecycle controller for proposed plan output.
plan_stream_controller: Option<PlanStreamController>,
// Latest completed user-visible Codex output that `/copy` should place on the clipboard.
last_copyable_output: Option<String>,
running_commands: HashMap<String, RunningCommand>,
suppressed_exec_calls: HashSet<String>,
skills_all: Vec<ProtocolSkillMetadata>,
@@ -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<RenderedUserMessageEvent>,
}
/// 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<String>, 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::<Vec<_>>();
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();
}
}
+298
View File
@@ -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<String>,
warned_audio_only_submission: bool,
meter_placeholder_id: Option<String>,
#[cfg(not(target_os = "linux"))]
capture_stop_flag: Option<Arc<AtomicBool>>,
#[cfg(not(target_os = "linux"))]
capture: Option<crate::voice::VoiceCapture>,
#[cfg(not(target_os = "linux"))]
audio_player: Option<crate::voice::RealtimeAudioPlayer>,
}
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<String>,
local_images: Vec<PathBuf>,
text_elements: Vec<TextElement>,
}
impl ChatWidget {
pub(super) fn rendered_user_message_event_from_parts(
message: String,
text_elements: Vec<TextElement>,
local_images: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> 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<UserMessage> {
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<String>) {
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;
}
}
@@ -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.
+200
View File
@@ -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;
+11
View File
@@ -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())
}
+558 -78
View File
@@ -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<PathBuf, FileChange>,
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<RtLine<'static>> {
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<RtLine<'static>> {
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<RtLine<'static>> {
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<RtSpan<'static>> = 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<RtSpan<'static>> = 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<RtLine<'static>> = 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<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
@@ -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<PathBuf, FileChange> = HashMap::new();
+24 -5
View File
@@ -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<Self, String> {
Err("voice input is unavailable in this build".to_string())
}
pub fn start_realtime(_tx: AppEventSender) -> Result<Self, String> {
Err("voice input is unavailable in this build".to_string())
}
pub fn stop(self) -> Result<RecordedAudio, String> {
Err("voice input is unavailable in this build".to_string())
}
@@ -175,6 +183,18 @@ mod voice {
}
}
impl RealtimeAudioPlayer {
pub(crate) fn start() -> Result<Self, String> {
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<PathBuf>,
) -> std::io::Result<AppExitInfo> {
pub async fn run_main(mut cli: Cli, arg0_paths: Arg0DispatchPaths) -> std::io::Result<AppExitInfo> {
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()
+3 -2
View File
@@ -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!(
+7
View File
@@ -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,
}
+32 -11
View File
@@ -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()
}
}
+1
View File
@@ -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()
+317
View File
@@ -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<Self, String> {
let (device, config) = select_input_device_and_config()?;
let sample_rate = config.sample_rate().0;
let channels = config.channels();
let data: Arc<Mutex<Vec<i16>>> = 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<RecordedAudio, String> {
// 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<AtomicU16>,
) -> Result<cpal::Stream, String> {
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::<Vec<_>>();
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<i16>,
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<i16>) -> u16 {
peak as u16
}
// -------------------------
// Realtime audio playback helpers
// -------------------------
pub(crate) struct RealtimeAudioPlayer {
_stream: cpal::Stream,
queue: Arc<Mutex<VecDeque<i16>>>,
output_sample_rate: u32,
output_channels: u16,
}
impl RealtimeAudioPlayer {
pub(crate) fn start() -> Result<Self, String> {
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<Mutex<VecDeque<i16>>>,
) -> Result<cpal::Stream, String> {
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<Mutex<VecDeque<i16>>>) {
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<Mutex<VecDeque<i16>>>) {
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<Mutex<VecDeque<i16>>>) {
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<i16> {
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
// -------------------------
+14 -1
View File
@@ -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",
] }
+16 -4
View File
@@ -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)]
@@ -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<LinuxBackend>,
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<Child, std::io::Error> {
// 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));
}
}
@@ -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<PowerRequest>,
}
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<Self, String> {
let mut wide_reason: Vec<u16> = 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"
);
}
}
}
+6
View File
@@ -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.