mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge c5b2e2af26 into sapling-pr-archive-bolinfest
This commit is contained in:
Generated
+3
@@ -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]]
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ use crate::function_tool::FunctionCallError;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::shell::Shell;
|
||||
use crate::skills::SKILL_APPROVAL_DECLINED_MESSAGE;
|
||||
use crate::skills::ensure_skill_approval_for_command;
|
||||
use crate::skills::maybe_emit_implicit_skill_invocation;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
@@ -66,6 +68,8 @@ impl ShellHandler {
|
||||
) -> ExecParams {
|
||||
ExecParams {
|
||||
command: params.command.clone(),
|
||||
original_command: shlex::try_join(params.command.iter().map(String::as_str))
|
||||
.unwrap_or_else(|_| params.command.join(" ")),
|
||||
cwd: turn_context.resolve_path(params.workdir.clone()),
|
||||
expiration: params.timeout_ms.into(),
|
||||
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
|
||||
@@ -116,6 +120,7 @@ impl ShellCommandHandler {
|
||||
|
||||
Ok(ExecParams {
|
||||
command,
|
||||
original_command: params.command.clone(),
|
||||
cwd: turn_context.resolve_path(params.workdir.clone()),
|
||||
expiration: params.timeout_ms.into(),
|
||||
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
|
||||
@@ -171,6 +176,7 @@ impl ToolHandler for ShellHandler {
|
||||
call_id,
|
||||
tool_name,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
match payload {
|
||||
@@ -256,6 +262,7 @@ impl ToolHandler for ShellCommandHandler {
|
||||
call_id,
|
||||
tool_name,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let ToolPayload::Function { arguments } = payload else {
|
||||
@@ -265,13 +272,6 @@ impl ToolHandler for ShellCommandHandler {
|
||||
};
|
||||
|
||||
let params: ShellCommandToolCallParams = parse_arguments(&arguments)?;
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
¶ms.command,
|
||||
params.workdir.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
let exec_params = Self::to_exec_params(
|
||||
¶ms,
|
||||
@@ -348,6 +348,28 @@ impl ShellHandler {
|
||||
"approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}"
|
||||
)));
|
||||
}
|
||||
let original_command = exec_params.original_command.as_str();
|
||||
if !ensure_skill_approval_for_command(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
original_command,
|
||||
exec_params.cwd.as_path(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
SKILL_APPROVAL_DECLINED_MESSAGE.to_string(),
|
||||
));
|
||||
}
|
||||
let workdir = exec_params.cwd.to_string_lossy().into_owned();
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
original_command,
|
||||
Some(workdir.as_str()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Intercept apply_patch if present.
|
||||
if let Some(output) = intercept_apply_patch(
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(" "))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¶ms.input_headers)?;
|
||||
let output_schema_json = params
|
||||
.output_schema_json
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()?;
|
||||
let max_runtime_seconds = params
|
||||
.max_runtime_seconds
|
||||
.map(i64::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| anyhow::anyhow!("invalid max_runtime_seconds value"))?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO agent_jobs (
|
||||
id,
|
||||
name,
|
||||
status,
|
||||
instruction,
|
||||
auto_export,
|
||||
max_runtime_seconds,
|
||||
output_schema_json,
|
||||
input_headers_json,
|
||||
input_csv_path,
|
||||
output_csv_path,
|
||||
created_at,
|
||||
updated_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
last_error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
||||
"#,
|
||||
)
|
||||
.bind(params.id.as_str())
|
||||
.bind(params.name.as_str())
|
||||
.bind(AgentJobStatus::Pending.as_str())
|
||||
.bind(params.instruction.as_str())
|
||||
.bind(i64::from(params.auto_export))
|
||||
.bind(max_runtime_seconds)
|
||||
.bind(output_schema_json)
|
||||
.bind(input_headers_json)
|
||||
.bind(params.input_csv_path.as_str())
|
||||
.bind(params.output_csv_path.as_str())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for item in items {
|
||||
let row_json = serde_json::to_string(&item.row_json)?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO agent_job_items (
|
||||
job_id,
|
||||
item_id,
|
||||
row_index,
|
||||
source_id,
|
||||
row_json,
|
||||
status,
|
||||
assigned_thread_id,
|
||||
attempt_count,
|
||||
result_json,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
reported_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, NULL, 0, NULL, NULL, ?, ?, NULL, NULL)
|
||||
"#,
|
||||
)
|
||||
.bind(params.id.as_str())
|
||||
.bind(item.item_id.as_str())
|
||||
.bind(item.row_index)
|
||||
.bind(item.source_id.as_deref())
|
||||
.bind(row_json)
|
||||
.bind(AgentJobItemStatus::Pending.as_str())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let job_id = params.id.as_str();
|
||||
self.get_agent_job(job_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to load created agent job {job_id}"))
|
||||
}
|
||||
|
||||
pub async fn get_agent_job(&self, job_id: &str) -> anyhow::Result<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#"
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
// -------------------------
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user