Make AGENTS.md discovery FS-aware (#15826)

## Summary
- make AGENTS.md discovery and loading fully FS-aware and remove the
non-FS discover helper
- migrate remote-aware codex-core tests to use TestEnv workspace setup
instead of syncing a local workspace copy
- add AGENTS.md corner-case coverage, including directory fallbacks and
remote-aware integration coverage

## Testing
- cargo test -p codex-core project_doc -- --nocapture
- cargo test -p codex-core hierarchical_agents -- --nocapture
- cargo test -p codex-core agents_md -- --nocapture
- cargo test -p codex-tui status -- --nocapture
- cargo test -p codex-tui-app-server status -- --nocapture
- just fix
- just fmt
- just bazel-lock-update
- just bazel-lock-check
- just argument-comment-lint
- remote Linux executor tests in progress via scripts/test-remote-env.sh
This commit is contained in:
pakrym-oai authored and GitHub committed 2026-04-06 20:26:21 -07:00
1 parent 232db0613a
commit 4bb507d2c4
21 files changed
+545 -171

No files matched your search

+1
View File
@@ -2794,6 +2794,7 @@ dependencies = [
"codex-cloud-requirements", "codex-cloud-requirements",
"codex-config", "codex-config",
"codex-core", "codex-core",
"codex-exec-server",
"codex-features", "codex-features",
"codex-feedback", "codex-feedback",
"codex-file-search", "codex-file-search",
+8 -4
View File
@@ -536,7 +536,11 @@ impl Codex {
config.startup_warnings.push(message); config.startup_warnings.push(message);
} }
let user_instructions = get_user_instructions(&config).await; let environment = environment_manager
.current()
.await
.map_err(|err| CodexErr::Fatal(format!("failed to create environment: {err}")))?;
let user_instructions = get_user_instructions(&config, environment.as_deref()).await;
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
// Guardian review should rely on the built-in shell safety checks, // Guardian review should rely on the built-in shell safety checks,
@@ -664,12 +668,12 @@ impl Codex {
agent_status_tx.clone(), agent_status_tx.clone(),
conversation_history, conversation_history,
session_source_clone, session_source_clone,
environment_manager,
skills_manager, skills_manager,
plugins_manager, plugins_manager,
mcp_manager.clone(), mcp_manager.clone(),
skills_watcher, skills_watcher,
agent_control, agent_control,
environment,
) )
.await .await
.map_err(|e| { .map_err(|e| {
@@ -1518,12 +1522,12 @@ impl Session {
agent_status: watch::Sender<AgentStatus>, agent_status: watch::Sender<AgentStatus>,
initial_history: InitialHistory, initial_history: InitialHistory,
session_source: SessionSource, session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
skills_manager: Arc<SkillsManager>, skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>, plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>, mcp_manager: Arc<McpManager>,
skills_watcher: Arc<SkillsWatcher>, skills_watcher: Arc<SkillsWatcher>,
agent_control: AgentControl, agent_control: AgentControl,
environment: Option<Arc<Environment>>,
) -> anyhow::Result<Arc<Self>> { ) -> anyhow::Result<Arc<Self>> {
debug!( debug!(
"Configuring session: model={}; provider={:?}", "Configuring session: model={}; provider={:?}",
@@ -1963,7 +1967,7 @@ impl Session {
code_mode_service: crate::tools::code_mode::CodeModeService::new( code_mode_service: crate::tools::code_mode::CodeModeService::new(
config.js_repl_node_path.clone(), config.js_repl_node_path.clone(),
), ),
environment: environment_manager.current().await?, environment,
}; };
services services
.model_client .model_client
+5 -3
View File
@@ -2612,14 +2612,16 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
agent_status_tx, agent_status_tx,
InitialHistory::New, InitialHistory::New,
SessionSource::Exec, SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::new(
/*exec_server_url*/ None,
)),
skills_manager, skills_manager,
plugins_manager, plugins_manager,
mcp_manager, mcp_manager,
Arc::new(SkillsWatcher::noop()), Arc::new(SkillsWatcher::noop()),
AgentControl::default(), AgentControl::default(),
Some(Arc::new(
codex_exec_server::Environment::create(/*exec_server_url*/ None)
.await
.expect("create environment"),
)),
) )
.await; .await;
+72 -39
View File
@@ -21,10 +21,12 @@ use crate::config_loader::default_project_root_markers;
use crate::config_loader::merge_toml_values; use crate::config_loader::merge_toml_values;
use crate::config_loader::project_root_markers_from_config; use crate::config_loader::project_root_markers_from_config;
use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigLayerSource;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_features::Feature; use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize as normalize_path; use dunce::canonicalize as normalize_path;
use std::path::PathBuf; use std::io;
use tokio::io::AsyncReadExt;
use toml::Value as TomlValue; use toml::Value as TomlValue;
use tracing::error; use tracing::error;
@@ -76,8 +78,19 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single /// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
/// string of instructions. /// string of instructions.
pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> { pub(crate) async fn get_user_instructions(
let project_docs = read_project_docs(config).await; config: &Config,
environment: Option<&Environment>,
) -> Option<String> {
let fs = environment?.get_filesystem();
get_user_instructions_with_fs(config, fs.as_ref()).await
}
pub(crate) async fn get_user_instructions_with_fs(
config: &Config,
fs: &dyn ExecutorFileSystem,
) -> Option<String> {
let project_docs = read_project_docs_with_fs(config, fs).await;
let mut output = String::new(); let mut output = String::new();
@@ -125,14 +138,25 @@ pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
/// concatenation of all discovered docs. If no documentation file is found the /// concatenation of all discovered docs. If no documentation file is found the
/// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so /// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so
/// callers can decide how to handle them. /// callers can decide how to handle them.
pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String>> { pub async fn read_project_docs(
config: &Config,
environment: &Environment,
) -> io::Result<Option<String>> {
let fs = environment.get_filesystem();
read_project_docs_with_fs(config, fs.as_ref()).await
}
async fn read_project_docs_with_fs(
config: &Config,
fs: &dyn ExecutorFileSystem,
) -> io::Result<Option<String>> {
let max_total = config.project_doc_max_bytes; let max_total = config.project_doc_max_bytes;
if max_total == 0 { if max_total == 0 {
return Ok(None); return Ok(None);
} }
let paths = discover_project_doc_paths(config)?; let paths = discover_project_doc_paths(config, fs).await?;
if paths.is_empty() { if paths.is_empty() {
return Ok(None); return Ok(None);
} }
@@ -145,16 +169,22 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String
break; break;
} }
let file = match tokio::fs::File::open(&p).await { match fs.get_metadata(&p).await {
Ok(f) => f, Ok(metadata) if !metadata.is_file => continue,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Ok(_) => {}
Err(e) => return Err(e), Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
}; Err(err) => return Err(err),
}
let size = file.metadata().await?.len(); let mut data = match fs.read_file(&p).await {
let mut reader = tokio::io::BufReader::new(file).take(remaining); Ok(data) => data,
let mut data: Vec<u8> = Vec::new(); Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
reader.read_to_end(&mut data).await?; Err(err) => return Err(err),
};
let size = data.len() as u64;
if size > remaining {
data.truncate(remaining as usize);
}
if size > remaining { if size > remaining {
tracing::warn!( tracing::warn!(
@@ -183,10 +213,17 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String
/// contents. The list is ordered from project 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` /// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
/// is zero, returns an empty list. /// is zero, returns an empty list.
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> { pub async fn discover_project_doc_paths(
let mut dir = config.cwd.to_path_buf(); config: &Config,
fs: &dyn ExecutorFileSystem,
) -> io::Result<Vec<AbsolutePathBuf>> {
if config.project_doc_max_bytes == 0 {
return Ok(Vec::new());
}
let mut dir = config.cwd.clone();
if let Ok(canon) = normalize_path(&dir) { if let Ok(canon) = normalize_path(&dir) {
dir = canon; dir = AbsolutePathBuf::try_from(canon)?;
} }
let mut merged = TomlValue::Table(toml::map::Map::new()); let mut merged = TomlValue::Table(toml::map::Map::new());
@@ -211,14 +248,14 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
if !project_root_markers.is_empty() { if !project_root_markers.is_empty() {
for ancestor in dir.ancestors() { for ancestor in dir.ancestors() {
for marker in &project_root_markers { for marker in &project_root_markers {
let marker_path = ancestor.join(marker); let marker_path = AbsolutePathBuf::try_from(ancestor.join(marker))?;
let marker_exists = match std::fs::metadata(&marker_path) { let marker_exists = match fs.get_metadata(&marker_path).await {
Ok(_) => true, Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, Err(err) if err.kind() == io::ErrorKind::NotFound => false,
Err(e) => return Err(e), Err(err) => return Err(err),
}; };
if marker_exists { if marker_exists {
project_root = Some(ancestor.to_path_buf()); project_root = Some(AbsolutePathBuf::try_from(ancestor.to_path_buf())?);
break; break;
} }
} }
@@ -228,11 +265,11 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
} }
} }
let search_dirs: Vec<PathBuf> = if let Some(root) = project_root { let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
let mut cursor = dir.as_path(); let mut cursor = dir.clone();
loop { loop {
dirs.push(cursor.to_path_buf()); dirs.push(cursor.clone());
if cursor == root { if cursor == root {
break; break;
} }
@@ -247,29 +284,25 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
vec![dir] vec![dir]
}; };
let mut found: Vec<PathBuf> = Vec::new(); let mut found: Vec<AbsolutePathBuf> = Vec::new();
let candidate_filenames = candidate_filenames(config); let candidate_filenames = candidate_filenames(config);
for d in search_dirs { for d in search_dirs {
for name in &candidate_filenames { for name in &candidate_filenames {
let candidate = d.join(name); let candidate = d.join(name)?;
match std::fs::symlink_metadata(&candidate) { match fs.get_metadata(&candidate).await {
Ok(md) => { Ok(md) if md.is_file => {
let ft = md.file_type(); found.push(candidate);
// Allow regular files and symlinks; opening will later fail for dangling links. break;
if ft.is_file() || ft.is_symlink() {
found.push(candidate);
break;
}
} }
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Ok(_) => {}
Err(e) => return Err(e), Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
} }
} }
} }
Ok(found) Ok(found)
} }
fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> { fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
let mut names: Vec<&'a str> = let mut names: Vec<&'a str> =
Vec::with_capacity(2 + config.project_doc_fallback_filenames.len()); Vec::with_capacity(2 + config.project_doc_fallback_filenames.len());
+118 -7
View File
@@ -1,12 +1,23 @@
use super::*; use super::*;
use crate::config::ConfigBuilder; use crate::config::ConfigBuilder;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature; use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt; use core_test_support::PathBufExt;
use core_test_support::TempDirExt; use core_test_support::TempDirExt;
use pretty_assertions::assert_eq;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use tempfile::TempDir; use tempfile::TempDir;
async fn get_user_instructions(config: &Config) -> Option<String> {
super::get_user_instructions_with_fs(config, LOCAL_FS.as_ref()).await
}
async fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
super::discover_project_doc_paths(config, LOCAL_FS.as_ref()).await
}
/// Helper that returns a `Config` pointing at `root` and using `limit` as /// Helper that returns a `Config` pointing at `root` and using `limit` as
/// the maximum number of bytes to embed from AGENTS.md. The caller can /// the maximum number of bytes to embed from AGENTS.md. The caller can
/// optionally specify a custom `instructions` string – when `None` the /// optionally specify a custom `instructions` string – when `None` the
@@ -85,6 +96,16 @@ async fn no_doc_file_returns_none() {
assert!(res.is_none(), "Expected None when AGENTS.md is absent"); assert!(res.is_none(), "Expected None when AGENTS.md is absent");
} }
#[tokio::test]
async fn no_environment_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
let res = super::get_user_instructions(&config, /*environment*/ None).await;
assert_eq!(res, None);
}
/// Small file within the byte-limit is returned unmodified. /// Small file within the byte-limit is returned unmodified.
#[tokio::test] #[tokio::test]
async fn doc_smaller_than_limit_is_returned() { async fn doc_smaller_than_limit_is_returned() {
@@ -161,6 +182,18 @@ async fn zero_byte_limit_disables_docs() {
); );
} }
#[tokio::test]
async fn zero_byte_limit_disables_discovery() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
let discovery =
discover_project_doc_paths(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await)
.await
.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
}
#[tokio::test] #[tokio::test]
async fn js_repl_instructions_are_appended_when_enabled() { async fn js_repl_instructions_are_appended_when_enabled() {
let tmp = tempfile::tempdir().expect("tempdir"); let tmp = tempfile::tempdir().expect("tempdir");
@@ -293,11 +326,18 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
.await; .await;
cfg.cwd = nested.abs(); cfg.cwd = nested.abs();
let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); let discovery = discover_project_doc_paths(&cfg)
let expected_parent = .await
dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"); .expect("discover paths");
let expected_child = let expected_parent = AbsolutePathBuf::try_from(
dunce::canonicalize(cfg.cwd.as_path().join("AGENTS.md")).expect("canonical child doc path"); dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"),
)
.expect("absolute parent doc path");
let expected_child = AbsolutePathBuf::try_from(
dunce::canonicalize(cfg.cwd.join("AGENTS.md").expect("absolute child doc path"))
.expect("canonical child doc path"),
)
.expect("absolute child doc path");
assert_eq!(discovery.len(), 2); assert_eq!(discovery.len(), 2);
assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[0], expected_parent);
assert_eq!(discovery[1], expected_child); assert_eq!(discovery[1], expected_child);
@@ -321,7 +361,9 @@ async fn agents_local_md_preferred() {
assert_eq!(res, "local"); assert_eq!(res, "local");
let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery.len(), 1); assert_eq!(discovery.len(), 1);
assert_eq!( assert_eq!(
discovery[0].file_name().unwrap().to_string_lossy(), discovery[0].file_name().unwrap().to_string_lossy(),
@@ -371,7 +413,9 @@ async fn agents_md_preferred_over_fallbacks() {
assert_eq!(res, "primary"); assert_eq!(res, "primary");
let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery.len(), 1); assert_eq!(discovery.len(), 1);
assert!( assert!(
discovery[0] discovery[0]
@@ -382,6 +426,73 @@ async fn agents_md_preferred_over_fallbacks() {
); );
} }
#[tokio::test]
async fn agents_md_directory_is_ignored() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir(tmp.path().join("AGENTS.md")).unwrap();
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let res = get_user_instructions(&cfg).await;
assert_eq!(res, None);
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
}
#[cfg(unix)]
#[tokio::test]
async fn agents_md_special_file_is_ignored() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("AGENTS.md");
let c_path = CString::new(path.as_os_str().as_bytes()).expect("path without nul");
// SAFETY: `c_path` is a valid, nul-terminated path and `mkfifo` does not
// retain the pointer after the call.
let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
assert_eq!(rc, 0);
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let res = get_user_instructions(&cfg).await;
assert_eq!(res, None);
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
}
#[tokio::test]
async fn override_directory_falls_back_to_agents_md_file() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME)).unwrap();
fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "primary").unwrap();
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let res = get_user_instructions(&cfg)
.await
.expect("AGENTS.md should be used when override is a directory");
assert_eq!(res, "primary");
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery.len(), 1);
assert_eq!(
discovery[0]
.file_name()
.expect("file name")
.to_string_lossy(),
DEFAULT_PROJECT_DOC_FILENAME
);
}
#[tokio::test] #[tokio::test]
async fn skills_are_not_appended_to_project_doc() { async fn skills_are_not_appended_to_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir"); let tmp = tempfile::tempdir().expect("tempdir");
+74 -44
View File
@@ -1,3 +1,4 @@
use std::future::Future;
use std::mem::swap; use std::mem::swap;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
@@ -34,12 +35,12 @@ use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput; use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use futures::future::BoxFuture;
use serde_json::Value; use serde_json::Value;
use tempfile::TempDir; use tempfile::TempDir;
use wiremock::MockServer; use wiremock::MockServer;
use crate::PathBufExt; use crate::PathBufExt;
use crate::PathExt;
use crate::RemoteEnvConfig; use crate::RemoteEnvConfig;
use crate::TempDirExt; use crate::TempDirExt;
use crate::get_remote_test_env; use crate::get_remote_test_env;
@@ -55,6 +56,8 @@ use wiremock::matchers::path_regex;
type ConfigMutator = dyn FnOnce(&mut Config) + Send; type ConfigMutator = dyn FnOnce(&mut Config) + Send;
type PreBuildHook = dyn FnOnce(&Path) + Send + 'static; type PreBuildHook = dyn FnOnce(&Path) + Send + 'static;
type WorkspaceSetup = dyn FnOnce(AbsolutePathBuf, Arc<dyn ExecutorFileSystem>) -> BoxFuture<'static, Result<()>>
+ Send;
const TEST_MODEL_WITH_EXPERIMENTAL_TOOLS: &str = "test-gpt-5.1-codex"; const TEST_MODEL_WITH_EXPERIMENTAL_TOOLS: &str = "test-gpt-5.1-codex";
const REMOTE_EXEC_SERVER_START_TIMEOUT: Duration = Duration::from_secs(5); const REMOTE_EXEC_SERVER_START_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_EXEC_SERVER_POLL_INTERVAL: Duration = Duration::from_millis(25); const REMOTE_EXEC_SERVER_POLL_INTERVAL: Duration = Duration::from_millis(25);
@@ -97,24 +100,28 @@ impl RemoteExecServerProcess {
#[derive(Debug)] #[derive(Debug)]
pub struct TestEnv { pub struct TestEnv {
environment: codex_exec_server::Environment, environment: codex_exec_server::Environment,
cwd: PathBuf, cwd: AbsolutePathBuf,
_local_cwd_temp_dir: Option<TempDir>, local_cwd_temp_dir: Option<Arc<TempDir>>,
_remote_exec_server_process: Option<RemoteExecServerProcess>, _remote_exec_server_process: Option<RemoteExecServerProcess>,
} }
impl TestEnv { impl TestEnv {
pub async fn local() -> Result<Self> { pub async fn local() -> Result<Self> {
let local_cwd_temp_dir = TempDir::new()?; let local_cwd_temp_dir = Arc::new(TempDir::new()?);
let cwd = local_cwd_temp_dir.path().to_path_buf(); let cwd = local_cwd_temp_dir.abs();
let environment = codex_exec_server::Environment::create(/*exec_server_url*/ None).await?; let environment = codex_exec_server::Environment::create(/*exec_server_url*/ None).await?;
Ok(Self { Ok(Self {
environment, environment,
cwd, cwd,
_local_cwd_temp_dir: Some(local_cwd_temp_dir), local_cwd_temp_dir: Some(local_cwd_temp_dir),
_remote_exec_server_process: None, _remote_exec_server_process: None,
}) })
} }
pub fn cwd(&self) -> &AbsolutePathBuf {
&self.cwd
}
pub fn environment(&self) -> &codex_exec_server::Environment { pub fn environment(&self) -> &codex_exec_server::Environment {
&self.environment &self.environment
} }
@@ -122,6 +129,10 @@ impl TestEnv {
pub fn exec_server_url(&self) -> Option<&str> { pub fn exec_server_url(&self) -> Option<&str> {
self.environment.exec_server_url() self.environment.exec_server_url()
} }
fn local_cwd_temp_dir(&self) -> Option<Arc<TempDir>> {
self.local_cwd_temp_dir.clone()
}
} }
pub async fn test_env() -> Result<TestEnv> { pub async fn test_env() -> Result<TestEnv> {
@@ -134,16 +145,13 @@ pub async fn test_env() -> Result<TestEnv> {
let cwd = remote_aware_cwd_path(); let cwd = remote_aware_cwd_path();
environment environment
.get_filesystem() .get_filesystem()
.create_directory( .create_directory(&cwd, CreateDirectoryOptions { recursive: true })
&absolute_path(&cwd)?,
CreateDirectoryOptions { recursive: true },
)
.await?; .await?;
remote_process.process.register_cleanup_path(&cwd); remote_process.process.register_cleanup_path(cwd.as_path());
Ok(TestEnv { Ok(TestEnv {
environment, environment,
cwd, cwd,
_local_cwd_temp_dir: None, local_cwd_temp_dir: None,
_remote_exec_server_process: Some(remote_process.process), _remote_exec_server_process: Some(remote_process.process),
}) })
} }
@@ -201,11 +209,12 @@ echo $!"
}) })
} }
fn remote_aware_cwd_path() -> PathBuf { fn remote_aware_cwd_path() -> AbsolutePathBuf {
PathBuf::from(format!( PathBuf::from(format!(
"/tmp/codex-core-test-cwd-{}", "/tmp/codex-core-test-cwd-{}",
remote_exec_server_instance_id() remote_exec_server_instance_id()
)) ))
.abs()
} }
fn wait_for_remote_listen_url(container_name: &str, stdout_path: &str) -> Result<String> { fn wait_for_remote_listen_url(container_name: &str, stdout_path: &str) -> Result<String> {
@@ -299,10 +308,6 @@ fn docker_command_capture_stdout<const N: usize>(args: [&str; N]) -> Result<Stri
String::from_utf8(output.stdout).context("docker stdout must be utf-8") String::from_utf8(output.stdout).context("docker stdout must be utf-8")
} }
fn absolute_path(path: &Path) -> Result<AbsolutePathBuf> {
Ok(path.abs())
}
/// A collection of different ways the model can output an apply_patch call /// A collection of different ways the model can output an apply_patch call
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ApplyPatchModelOutput { pub enum ApplyPatchModelOutput {
@@ -326,6 +331,7 @@ pub struct TestCodexBuilder {
config_mutators: Vec<Box<ConfigMutator>>, config_mutators: Vec<Box<ConfigMutator>>,
auth: CodexAuth, auth: CodexAuth,
pre_build_hooks: Vec<Box<PreBuildHook>>, pre_build_hooks: Vec<Box<PreBuildHook>>,
workspace_setups: Vec<Box<WorkspaceSetup>>,
home: Option<Arc<TempDir>>, home: Option<Arc<TempDir>>,
user_shell_override: Option<Shell>, user_shell_override: Option<Shell>,
} }
@@ -359,6 +365,16 @@ impl TestCodexBuilder {
self self
} }
pub fn with_workspace_setup<F, Fut>(mut self, setup: F) -> Self
where
F: FnOnce(AbsolutePathBuf, Arc<dyn ExecutorFileSystem>) -> Fut + Send + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
self.workspace_setups
.push(Box::new(move |cwd, fs| Box::pin(setup(cwd, fs))));
self
}
pub fn with_home(mut self, home: Arc<TempDir>) -> Self { pub fn with_home(mut self, home: Arc<TempDir>) -> Self {
self.home = Some(home); self.home = Some(home);
self self
@@ -382,25 +398,24 @@ impl TestCodexBuilder {
Some(home) => home, Some(home) => home,
None => Arc::new(TempDir::new()?), None => Arc::new(TempDir::new()?),
}; };
Box::pin(self.build_with_home(server, home, /*resume_from*/ None)).await let base_url = format!("{}/v1", server.uri());
let test_env = TestEnv::local().await?;
Box::pin(self.build_with_home_and_base_url(base_url, home, /*resume_from*/ None, test_env))
.await
} }
pub async fn build_remote_aware( pub async fn build_remote_aware(
&mut self, &mut self,
server: &wiremock::MockServer, server: &wiremock::MockServer,
) -> anyhow::Result<TestCodex> { ) -> anyhow::Result<TestCodex> {
let test_env = test_env().await?;
let home = match self.home.clone() { let home = match self.home.clone() {
Some(home) => home, Some(home) => home,
None => Arc::new(TempDir::new()?), None => Arc::new(TempDir::new()?),
}; };
let base_url = format!("{}/v1", server.uri()); let base_url = format!("{}/v1", server.uri());
let cwd = test_env.cwd.to_path_buf(); let test_env = test_env().await?;
self.config_mutators.push(Box::new(move |config| { Box::pin(self.build_with_home_and_base_url(base_url, home, /*resume_from*/ None, test_env))
config.cwd = cwd.abs(); .await
}));
let (config, cwd) = self.prepare_config(base_url, &home).await?;
Box::pin(self.build_from_config(config, cwd, home, /*resume_from*/ None, test_env)).await
} }
pub async fn build_with_streaming_server( pub async fn build_with_streaming_server(
@@ -412,10 +427,12 @@ impl TestCodexBuilder {
Some(home) => home, Some(home) => home,
None => Arc::new(TempDir::new()?), None => Arc::new(TempDir::new()?),
}; };
let test_env = TestEnv::local().await?;
Box::pin(self.build_with_home_and_base_url( Box::pin(self.build_with_home_and_base_url(
format!("{base_url}/v1"), format!("{base_url}/v1"),
home, home,
/*resume_from*/ None, /*resume_from*/ None,
test_env,
)) ))
.await .await
} }
@@ -435,7 +452,9 @@ impl TestCodexBuilder {
config.model_provider.supports_websockets = true; config.model_provider.supports_websockets = true;
config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); config.experimental_realtime_ws_model = Some("realtime-test-model".to_string());
})); }));
Box::pin(self.build_with_home_and_base_url(base_url, home, /*resume_from*/ None)).await let test_env = TestEnv::local().await?;
Box::pin(self.build_with_home_and_base_url(base_url, home, /*resume_from*/ None, test_env))
.await
} }
pub async fn resume( pub async fn resume(
@@ -443,19 +462,10 @@ impl TestCodexBuilder {
server: &wiremock::MockServer, server: &wiremock::MockServer,
home: Arc<TempDir>, home: Arc<TempDir>,
rollout_path: PathBuf, rollout_path: PathBuf,
) -> anyhow::Result<TestCodex> {
Box::pin(self.build_with_home(server, home, Some(rollout_path))).await
}
async fn build_with_home(
&mut self,
server: &wiremock::MockServer,
home: Arc<TempDir>,
resume_from: Option<PathBuf>,
) -> anyhow::Result<TestCodex> { ) -> anyhow::Result<TestCodex> {
let base_url = format!("{}/v1", server.uri()); let base_url = format!("{}/v1", server.uri());
let (config, cwd) = self.prepare_config(base_url, &home).await?; let test_env = TestEnv::local().await?;
Box::pin(self.build_from_config(config, cwd, home, resume_from, TestEnv::local().await?)) Box::pin(self.build_with_home_and_base_url(base_url, home, Some(rollout_path), test_env))
.await .await
} }
@@ -464,10 +474,30 @@ impl TestCodexBuilder {
base_url: String, base_url: String,
home: Arc<TempDir>, home: Arc<TempDir>,
resume_from: Option<PathBuf>, resume_from: Option<PathBuf>,
test_env: TestEnv,
) -> anyhow::Result<TestCodex> { ) -> anyhow::Result<TestCodex> {
let (config, cwd) = self.prepare_config(base_url, &home).await?; let (config, fallback_cwd) = self
Box::pin(self.build_from_config(config, cwd, home, resume_from, TestEnv::local().await?)) .prepare_config(base_url, &home, test_env.cwd().clone())
.await .await?;
let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new(
test_env.exec_server_url().map(str::to_owned),
));
let file_system = test_env.environment().get_filesystem();
let mut workspace_setups = vec![];
swap(&mut self.workspace_setups, &mut workspace_setups);
for setup in workspace_setups {
setup(config.cwd.clone(), Arc::clone(&file_system)).await?;
}
let cwd = test_env.local_cwd_temp_dir().unwrap_or(fallback_cwd);
Box::pin(self.build_from_config(
config,
cwd,
home,
resume_from,
test_env,
environment_manager,
))
.await
} }
async fn build_from_config( async fn build_from_config(
@@ -477,11 +507,9 @@ impl TestCodexBuilder {
home: Arc<TempDir>, home: Arc<TempDir>,
resume_from: Option<PathBuf>, resume_from: Option<PathBuf>,
test_env: TestEnv, test_env: TestEnv,
environment_manager: Arc<codex_exec_server::EnvironmentManager>,
) -> anyhow::Result<TestCodex> { ) -> anyhow::Result<TestCodex> {
let auth = self.auth.clone(); let auth = self.auth.clone();
let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new(
test_env.exec_server_url().map(str::to_owned),
));
let thread_manager = if config.model_catalog.is_some() { let thread_manager = if config.model_catalog.is_some() {
ThreadManager::new( ThreadManager::new(
&config, &config,
@@ -553,6 +581,7 @@ impl TestCodexBuilder {
&mut self, &mut self,
base_url: String, base_url: String,
home: &TempDir, home: &TempDir,
cwd_override: AbsolutePathBuf,
) -> anyhow::Result<(Config, Arc<TempDir>)> { ) -> anyhow::Result<(Config, Arc<TempDir>)> {
let model_provider = ModelProviderInfo { let model_provider = ModelProviderInfo {
base_url: Some(base_url), base_url: Some(base_url),
@@ -563,7 +592,7 @@ impl TestCodexBuilder {
}; };
let cwd = Arc::new(TempDir::new()?); let cwd = Arc::new(TempDir::new()?);
let mut config = load_default_config_for_test(home).await; let mut config = load_default_config_for_test(home).await;
config.cwd = cwd.abs(); config.cwd = cwd_override;
config.model_provider = model_provider; config.model_provider = model_provider;
for hook in self.pre_build_hooks.drain(..) { for hook in self.pre_build_hooks.drain(..) {
hook(home.path()); hook(home.path());
@@ -897,6 +926,7 @@ pub fn test_codex() -> TestCodexBuilder {
config_mutators: vec![], config_mutators: vec![],
auth: CodexAuth::from_api_key("dummy"), auth: CodexAuth::from_api_key("dummy"),
pre_build_hooks: vec![], pre_build_hooks: vec![],
workspace_setups: vec![],
home: None, home: None,
user_shell_override: None, user_shell_override: None,
} }
@@ -17,6 +17,7 @@ impl TestCodexExecBuilder {
); );
cmd.current_dir(self.cwd.path()) cmd.current_dir(self.cwd.path())
.env("CODEX_HOME", self.home.path()) .env("CODEX_HOME", self.home.path())
.env("CODEX_SQLITE_HOME", self.home.path())
.env(CODEX_API_KEY_ENV_VAR, "dummy"); .env(CODEX_API_KEY_ENV_VAR, "dummy");
cmd cmd
} }
+130
View File
@@ -0,0 +1,130 @@
use anyhow::Result;
use codex_exec_server::CreateDirectoryOptions;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::test_codex;
async fn agents_instructions(mut builder: TestCodexBuilder) -> Result<String> {
let server = start_mock_server().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let test = builder.build_remote_aware(&server).await?;
test.submit_turn("hello").await?;
let request = resp_mock.single_request();
request
.message_input_texts("user")
.into_iter()
.find(|text| text.starts_with("# AGENTS.md instructions for "))
.ok_or_else(|| anyhow::anyhow!("instructions message not found"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agents_override_is_preferred_over_agents_md() -> Result<()> {
let instructions =
agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move {
let agents_md = cwd.join("AGENTS.md").expect("absolute AGENTS.md path");
let override_md = cwd
.join("AGENTS.override.md")
.expect("absolute AGENTS.override.md path");
fs.write_file(&agents_md, b"base doc".to_vec()).await?;
fs.write_file(&override_md, b"override doc".to_vec())
.await?;
Ok::<(), anyhow::Error>(())
}))
.await?;
assert!(
instructions.contains("override doc"),
"expected AGENTS.override.md contents: {instructions}"
);
assert!(
!instructions.contains("base doc"),
"expected AGENTS.md to be ignored when override exists: {instructions}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Result<()> {
let instructions = agents_instructions(
test_codex()
.with_config(|config| {
config.project_doc_fallback_filenames = vec!["WORKFLOW.md".to_string()];
})
.with_workspace_setup(|cwd, fs| async move {
let agents_dir = cwd.join("AGENTS.md").expect("absolute AGENTS.md path");
let fallback = cwd.join("WORKFLOW.md").expect("absolute WORKFLOW.md path");
fs.create_directory(&agents_dir, CreateDirectoryOptions { recursive: true })
.await?;
fs.write_file(&fallback, b"fallback doc".to_vec()).await?;
Ok::<(), anyhow::Error>(())
}),
)
.await?;
assert!(
instructions.contains("fallback doc"),
"expected fallback doc contents: {instructions}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> {
let instructions = agents_instructions(
test_codex()
.with_config(|config| {
config.cwd = config
.cwd
.join("nested/workspace")
.expect("absolute nested workspace path");
})
.with_workspace_setup(|cwd, fs| async move {
let nested = cwd.clone();
let root = nested
.parent()
.and_then(|parent| parent.parent())
.expect("nested workspace should have a project root ancestor");
let root_agents = root
.join("AGENTS.md")
.expect("absolute root AGENTS.md path");
let git_marker = root.join(".git").expect("absolute .git path");
let nested_agents = nested
.join("AGENTS.md")
.expect("absolute nested AGENTS.md path");
fs.create_directory(&nested, CreateDirectoryOptions { recursive: true })
.await?;
fs.write_file(&root_agents, b"root doc".to_vec()).await?;
fs.write_file(&git_marker, b"gitdir: /tmp/mock-git-dir\n".to_vec())
.await?;
fs.write_file(&nested_agents, b"child doc".to_vec()).await?;
Ok::<(), anyhow::Error>(())
}),
)
.await?;
let root_pos = instructions
.find("root doc")
.expect("expected root doc in AGENTS instructions");
let child_pos = instructions
.find("child doc")
.expect("expected child doc in AGENTS instructions");
assert!(
root_pos < child_pos,
"expected root doc before child doc: {instructions}"
);
Ok(())
}
@@ -18,21 +18,22 @@ async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() {
) )
.await; .await;
let mut builder = test_codex().with_config(|config| { let mut builder = test_codex()
config .with_config(|config| {
.features
.enable(Feature::ChildAgentsMd)
.expect("test config should allow feature update");
std::fs::write(
config config
.cwd .features
.join("AGENTS.md") .enable(Feature::ChildAgentsMd)
.expect("absolute AGENTS.md path"), .expect("test config should allow feature update");
"be nice", })
) .with_workspace_setup(|cwd, fs| async move {
.expect("write AGENTS.md"); let agents_md = cwd.join("AGENTS.md").expect("absolute AGENTS.md path");
}); fs.write_file(&agents_md, b"be nice".to_vec()).await?;
let test = builder.build(&server).await.expect("build test codex"); Ok::<(), anyhow::Error>(())
});
let test = builder
.build_remote_aware(&server)
.await
.expect("build test codex");
test.submit_turn("hello").await.expect("submit turn"); test.submit_turn("hello").await.expect("submit turn");
@@ -73,7 +74,10 @@ async fn hierarchical_agents_emits_when_no_project_doc() {
.enable(Feature::ChildAgentsMd) .enable(Feature::ChildAgentsMd)
.expect("test config should allow feature update"); .expect("test config should allow feature update");
}); });
let test = builder.build(&server).await.expect("build test codex"); let test = builder
.build_remote_aware(&server)
.await
.expect("build test codex");
test.submit_turn("hello").await.expect("submit turn"); test.submit_turn("hello").await.expect("submit turn");
+1
View File
@@ -74,6 +74,7 @@ pub static CODEX_ALIASES_TEMP_DIR: Option<TestCodexAliasesGuard> = {
mod abort_tasks; mod abort_tasks;
mod agent_jobs; mod agent_jobs;
mod agent_websocket; mod agent_websocket;
mod agents_md;
mod apply_patch_cli; mod apply_patch_cli;
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
mod approvals; mod approvals;
+2 -9
View File
@@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use codex_exec_server::RemoveOptions; use codex_exec_server::RemoveOptions;
use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt;
use core_test_support::get_remote_test_env; use core_test_support::get_remote_test_env;
use core_test_support::test_codex::test_env; use core_test_support::test_codex::test_env;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@@ -17,8 +17,7 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
let test_env = test_env().await?; let test_env = test_env().await?;
let file_system = test_env.environment().get_filesystem(); let file_system = test_env.environment().get_filesystem();
let file_path = remote_test_file_path(); let file_path_abs = remote_test_file_path().abs();
let file_path_abs = absolute_path(file_path.clone())?;
let payload = b"remote-test-env-ok".to_vec(); let payload = b"remote-test-env-ok".to_vec();
file_system file_system
@@ -39,12 +38,6 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
Ok(()) Ok(())
} }
fn absolute_path(path: PathBuf) -> Result<AbsolutePathBuf> {
AbsolutePathBuf::try_from(path.clone())
.map_err(|err| anyhow::anyhow!("invalid absolute path {}: {err}", path.display()))
}
fn remote_test_file_path() -> PathBuf { fn remote_test_file_path() -> PathBuf {
let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) { let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_nanos(), Ok(duration) => duration.as_nanos(),
+1
View File
@@ -41,6 +41,7 @@ pub use file_system::FileMetadata;
pub use file_system::FileSystemResult; pub use file_system::FileSystemResult;
pub use file_system::ReadDirectoryEntry; pub use file_system::ReadDirectoryEntry;
pub use file_system::RemoveOptions; pub use file_system::RemoveOptions;
pub use local_file_system::LOCAL_FS;
pub use process::ExecBackend; pub use process::ExecBackend;
pub use process::ExecProcess; pub use process::ExecProcess;
pub use process::StartedExecProcess; pub use process::StartedExecProcess;
@@ -3,6 +3,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Component; use std::path::Component;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::SystemTime; use std::time::SystemTime;
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use tokio::io; use tokio::io;
@@ -17,6 +19,9 @@ use crate::RemoveOptions;
const MAX_READ_FILE_BYTES: u64 = 512 * 1024 * 1024; const MAX_READ_FILE_BYTES: u64 = 512 * 1024 * 1024;
pub static LOCAL_FS: LazyLock<Arc<dyn ExecutorFileSystem>> =
LazyLock::new(|| -> Arc<dyn ExecutorFileSystem> { Arc::new(LocalFileSystem) });
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub(crate) struct LocalFileSystem; pub(crate) struct LocalFileSystem;
@@ -23,6 +23,7 @@ use crate::ReadDirectoryEntry;
use crate::RemoveOptions; use crate::RemoveOptions;
const INVALID_REQUEST_ERROR_CODE: i64 = -32600; const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
const NOT_FOUND_ERROR_CODE: i64 = -32004;
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct RemoteFileSystem { pub(crate) struct RemoteFileSystem {
@@ -151,6 +152,9 @@ impl ExecutorFileSystem for RemoteFileSystem {
fn map_remote_error(error: ExecServerError) -> io::Error { fn map_remote_error(error: ExecServerError) -> io::Error {
match error { match error {
ExecServerError::Server { code, message } if code == NOT_FOUND_ERROR_CODE => {
io::Error::new(io::ErrorKind::NotFound, message)
}
ExecServerError::Server { code, message } if code == INVALID_REQUEST_ERROR_CODE => { ExecServerError::Server { code, message } if code == INVALID_REQUEST_ERROR_CODE => {
io::Error::new(io::ErrorKind::InvalidInput, message) io::Error::new(io::ErrorKind::InvalidInput, message)
} }
+8
View File
@@ -356,6 +356,14 @@ pub(crate) fn invalid_params(message: String) -> JSONRPCErrorError {
} }
} }
pub(crate) fn not_found(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32004,
data: None,
message,
}
}
pub(crate) fn internal_error(message: String) -> JSONRPCErrorError { pub(crate) fn internal_error(message: String) -> JSONRPCErrorError {
JSONRPCErrorError { JSONRPCErrorError {
code: -32603, code: -32603,
@@ -26,6 +26,7 @@ use crate::RemoveOptions;
use crate::local_file_system::LocalFileSystem; use crate::local_file_system::LocalFileSystem;
use crate::rpc::internal_error; use crate::rpc::internal_error;
use crate::rpc::invalid_request; use crate::rpc::invalid_request;
use crate::rpc::not_found;
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub(crate) struct FileSystemHandler { pub(crate) struct FileSystemHandler {
@@ -153,7 +154,9 @@ impl FileSystemHandler {
} }
fn map_fs_error(err: io::Error) -> JSONRPCErrorError { fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput { if err.kind() == io::ErrorKind::NotFound {
not_found(err.to_string())
} else if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string()) invalid_request(err.to_string())
} else { } else {
internal_error(err.to_string()) internal_error(err.to_string())
+1
View File
@@ -33,6 +33,7 @@ codex-chatgpt = { workspace = true }
codex-cloud-requirements = { workspace = true } codex-cloud-requirements = { workspace = true }
codex-config = { workspace = true } codex-config = { workspace = true }
codex-core = { workspace = true } codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true } codex-features = { workspace = true }
codex-feedback = { workspace = true } codex-feedback = { workspace = true }
codex-file-search = { workspace = true } codex-file-search = { workspace = true }
+15
View File
@@ -7303,6 +7303,8 @@ impl ChatWidget {
.values() .values()
.cloned() .cloned()
.collect(); .collect();
let config = self.config.clone();
let frame_requester = self.frame_requester.clone();
let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle(
&self.config, &self.config,
self.status_account_display.as_ref(), self.status_account_display.as_ref(),
@@ -7317,8 +7319,21 @@ impl ChatWidget {
self.model_display_name(), self.model_display_name(),
collaboration_mode, collaboration_mode,
reasoning_effort_override, reasoning_effort_override,
"<none>".to_string(),
refreshing_rate_limits, refreshing_rate_limits,
); );
let agents_summary_handle = handle.clone();
tokio::spawn(async move {
let agents_summary = match crate::status::discover_agents_summary(&config).await {
Ok(summary) => summary,
Err(err) => {
tracing::warn!(error = %err, "failed to discover project docs for /status");
"<none>".to_string()
}
};
agents_summary_handle.finish_agents_summary_discovery(agents_summary);
frame_requester.schedule_frame();
});
if let Some(request_id) = request_id { if let Some(request_id) = request_id {
self.refreshing_status_outputs.push((request_id, handle)); self.refreshing_status_outputs.push((request_id, handle));
} }
+28 -6
View File
@@ -28,7 +28,6 @@ use super::format::line_display_width;
use super::format::push_label; use super::format::push_label;
use super::format::truncate_line_to_width; use super::format::truncate_line_to_width;
use super::helpers::compose_account_display; use super::helpers::compose_account_display;
use super::helpers::compose_agents_summary;
use super::helpers::compose_model_display; use super::helpers::compose_model_display;
use super::helpers::format_directory_display; use super::helpers::format_directory_display;
use super::helpers::format_tokens_compact; use super::helpers::format_tokens_compact;
@@ -68,10 +67,20 @@ struct StatusRateLimitState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct StatusHistoryHandle { pub(crate) struct StatusHistoryHandle {
agents_summary: Arc<RwLock<String>>,
rate_limit_state: Arc<RwLock<StatusRateLimitState>>, rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
} }
impl StatusHistoryHandle { impl StatusHistoryHandle {
pub(crate) fn finish_agents_summary_discovery(&self, agents_summary: String) {
#[expect(clippy::expect_used)]
let mut current = self
.agents_summary
.write()
.expect("status history agents summary state poisoned");
*current = agents_summary;
}
pub(crate) fn finish_rate_limit_refresh( pub(crate) fn finish_rate_limit_refresh(
&self, &self,
rate_limits: &[RateLimitSnapshotDisplay], rate_limits: &[RateLimitSnapshotDisplay],
@@ -98,7 +107,7 @@ struct StatusHistoryCell {
model_details: Vec<String>, model_details: Vec<String>,
directory: PathBuf, directory: PathBuf,
permissions: String, permissions: String,
agents_summary: String, agents_summary: Arc<RwLock<String>>,
collaboration_mode: Option<String>, collaboration_mode: Option<String>,
model_provider: Option<String>, model_provider: Option<String>,
account: Option<StatusAccountDisplay>, account: Option<StatusAccountDisplay>,
@@ -177,6 +186,7 @@ pub(crate) fn new_status_output_with_rate_limits(
model_name, model_name,
collaboration_mode, collaboration_mode,
reasoning_effort_override, reasoning_effort_override,
"<none>".to_string(),
refreshing_rate_limits, refreshing_rate_limits,
) )
.0 .0
@@ -197,6 +207,7 @@ pub(crate) fn new_status_output_with_rate_limits_handle(
model_name: &str, model_name: &str,
collaboration_mode: Option<&str>, collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>, reasoning_effort_override: Option<Option<ReasoningEffort>>,
agents_summary: String,
refreshing_rate_limits: bool, refreshing_rate_limits: bool,
) -> (CompositeHistoryCell, StatusHistoryHandle) { ) -> (CompositeHistoryCell, StatusHistoryHandle) {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
@@ -214,6 +225,7 @@ pub(crate) fn new_status_output_with_rate_limits_handle(
model_name, model_name,
collaboration_mode, collaboration_mode,
reasoning_effort_override, reasoning_effort_override,
agents_summary,
refreshing_rate_limits, refreshing_rate_limits,
); );
@@ -239,6 +251,7 @@ impl StatusHistoryCell {
model_name: &str, model_name: &str,
collaboration_mode: Option<&str>, collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>, reasoning_effort_override: Option<Option<ReasoningEffort>>,
agents_summary: String,
refreshing_rate_limits: bool, refreshing_rate_limits: bool,
) -> (Self, StatusHistoryHandle) { ) -> (Self, StatusHistoryHandle) {
let mut config_entries = vec![ let mut config_entries = vec![
@@ -302,7 +315,6 @@ impl StatusHistoryCell {
} else { } else {
format!("Custom ({sandbox}, {approval})") format!("Custom ({sandbox}, {approval})")
}; };
let agents_summary = compose_agents_summary(config);
let model_provider = format_model_provider(config); let model_provider = format_model_provider(config);
let account = compose_account_display(account_display); let account = compose_account_display(account_display);
let session_id = session_id.as_ref().map(std::string::ToString::to_string); let session_id = session_id.as_ref().map(std::string::ToString::to_string);
@@ -333,6 +345,7 @@ impl StatusHistoryCell {
rate_limits, rate_limits,
refreshing_rate_limits, refreshing_rate_limits,
})); }));
let agents_summary = Arc::new(RwLock::new(agents_summary));
( (
Self { Self {
@@ -340,7 +353,6 @@ impl StatusHistoryCell {
model_details, model_details,
directory: config.cwd.to_path_buf(), directory: config.cwd.to_path_buf(),
permissions, permissions,
agents_summary,
collaboration_mode: collaboration_mode.map(ToString::to_string), collaboration_mode: collaboration_mode.map(ToString::to_string),
model_provider, model_provider,
account, account,
@@ -348,9 +360,13 @@ impl StatusHistoryCell {
session_id, session_id,
forked_from, forked_from,
token_usage, token_usage,
agents_summary: agents_summary.clone(),
rate_limit_state: rate_limit_state.clone(), rate_limit_state: rate_limit_state.clone(),
}, },
StatusHistoryHandle { rate_limit_state }, StatusHistoryHandle {
agents_summary,
rate_limit_state,
},
) )
} }
@@ -564,6 +580,12 @@ impl HistoryCell for StatusHistoryCell {
.rate_limit_state .rate_limit_state
.read() .read()
.expect("status history rate-limit state poisoned"); .expect("status history rate-limit state poisoned");
#[expect(clippy::expect_used)]
let agents_summary = self
.agents_summary
.read()
.expect("status history agents summary state poisoned")
.clone();
if self.model_provider.is_some() { if self.model_provider.is_some() {
push_label(&mut labels, &mut seen, "Model provider"); push_label(&mut labels, &mut seen, "Model provider");
@@ -625,7 +647,7 @@ impl HistoryCell for StatusHistoryCell {
} }
lines.push(formatter.line("Directory", vec![Span::from(directory_value)])); lines.push(formatter.line("Directory", vec![Span::from(directory_value)]));
lines.push(formatter.line("Permissions", vec![Span::from(self.permissions.clone())])); lines.push(formatter.line("Permissions", vec![Span::from(self.permissions.clone())]));
lines.push(formatter.line("Agents.md", vec![Span::from(self.agents_summary.clone())])); lines.push(formatter.line("Agents.md", vec![Span::from(agents_summary)]));
if let Some(account_value) = account_value { if let Some(account_value) = account_value {
lines.push(formatter.line("Account", vec![Span::from(account_value)])); lines.push(formatter.line("Account", vec![Span::from(account_value)]));
+47 -43
View File
@@ -5,7 +5,10 @@ use chrono::DateTime;
use chrono::Local; use chrono::Local;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::project_doc::discover_project_doc_paths; use codex_core::project_doc::discover_project_doc_paths;
use codex_exec_server::LOCAL_FS;
use codex_protocol::account::PlanType; use codex_protocol::account::PlanType;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::io;
use std::path::Path; use std::path::Path;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@@ -33,51 +36,52 @@ pub(crate) fn compose_model_display(
(model_name.to_string(), details) (model_name.to_string(), details)
} }
pub(crate) fn compose_agents_summary(config: &Config) -> String { pub(crate) async fn discover_agents_summary(config: &Config) -> io::Result<String> {
match discover_project_doc_paths(config) { let paths = discover_project_doc_paths(config, LOCAL_FS.as_ref()).await?;
Ok(paths) => { Ok(compose_agents_summary(config, &paths))
let mut rels: Vec<String> = Vec::new(); }
for p in paths {
let file_name = p pub(crate) fn compose_agents_summary(config: &Config, paths: &[AbsolutePathBuf]) -> String {
.file_name() let mut rels: Vec<String> = Vec::new();
.map(|name| name.to_string_lossy().to_string()) for p in paths {
.unwrap_or_else(|| "<unknown>".to_string()); let file_name = p
let display = if let Some(parent) = p.parent() { .file_name()
if parent == config.cwd.as_path() { .map(|name| name.to_string_lossy().to_string())
file_name.clone() .unwrap_or_else(|| "<unknown>".to_string());
} else { let display = if let Some(parent) = p.parent() {
let mut cur = config.cwd.as_path(); if parent.as_path() == config.cwd.as_path() {
let mut ups = 0usize; file_name.clone()
let mut reached = false;
while let Some(c) = cur.parent() {
if cur == parent {
reached = true;
break;
}
cur = c;
ups += 1;
}
if reached {
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}{}", up.repeat(ups), file_name)
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
normalize_agents_display_path(stripped)
} else {
normalize_agents_display_path(&p)
}
}
} else {
normalize_agents_display_path(&p)
};
rels.push(display);
}
if rels.is_empty() {
"<none>".to_string()
} else { } else {
rels.join(", ") let mut cur = config.cwd.as_path();
let mut ups = 0usize;
let mut reached = false;
while let Some(c) = cur.parent() {
if cur == parent.as_path() {
reached = true;
break;
}
cur = c;
ups += 1;
}
if reached {
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}{}", up.repeat(ups), file_name)
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
normalize_agents_display_path(stripped)
} else {
normalize_agents_display_path(p)
}
} }
} } else {
Err(_) => "<none>".to_string(), normalize_agents_display_path(p)
};
rels.push(display);
}
if rels.is_empty() {
"<none>".to_string()
} else {
rels.join(", ")
} }
} }
+1
View File
@@ -19,6 +19,7 @@ pub(crate) use card::new_status_output;
#[cfg(test)] #[cfg(test)]
pub(crate) use card::new_status_output_with_rate_limits; pub(crate) use card::new_status_output_with_rate_limits;
pub(crate) use card::new_status_output_with_rate_limits_handle; pub(crate) use card::new_status_output_with_rate_limits_handle;
pub(crate) use helpers::discover_agents_summary;
pub(crate) use helpers::format_directory_display; pub(crate) use helpers::format_directory_display;
pub(crate) use helpers::format_tokens_compact; pub(crate) use helpers::format_tokens_compact;
pub(crate) use helpers::plan_type_display_name; pub(crate) use helpers::plan_type_display_name;