Files
codex/codex-rs/core/tests/suite/agents_md.rs
T
Adam Perry @ OpenAI 59ca34206b [codex] Preserve logical paths during AGENTS.md discovery (#26465)
## Intent

Follow up on #26205 by avoiding unnecessary filesystem canonicalization
during `AGENTS.md` discovery. The configured working directory is
already absolute, and canonicalization incorrectly switches symlinked
workspaces from their logical parent hierarchy to the target's
hierarchy.

## User-facing behavior

For a symlinked working directory such as:

```text
test-root/
|-- logical-repo/
|   |-- AGENTS.md              ("logical parent doc")
|   `-- workspace ------------> physical-repo/workspace/
`-- physical-repo/
    |-- AGENTS.md              ("physical parent doc")
    `-- workspace/
        `-- AGENTS.md          ("workspace doc")
```

Before this change, Codex canonicalized `logical-repo/workspace` to
`physical-repo/workspace` before discovery. It therefore loaded
`physical-repo/AGENTS.md` and `physical-repo/workspace/AGENTS.md`,
ignoring the instructions from the repository through which the user
entered the workspace.

After this change, ancestor discovery walks the configured logical path,
so Codex loads `logical-repo/AGENTS.md`. Opening
`logical-repo/workspace/AGENTS.md` still follows the symlink through the
host filesystem, so the workspace document is also loaded.
`physical-repo/AGENTS.md` is not loaded.

## Implementation

Use the logical absolute working directory when discovering project
instructions and reporting instruction sources. Filesystem reads still
follow the working-directory symlink, so an `AGENTS.md` in the target
workspace continues to load while ancestor discovery uses the symlink's
parents.

## Validation

Added integration coverage proving that discovery loads the logical
parent's instructions and the target workspace's instructions, but not
the target parent's instructions.
2026-06-04 15:08:52 -07:00

271 lines
9.7 KiB
Rust

use anyhow::Result;
use codex_exec_server::CreateDirectoryOptions;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::create_directory_symlink;
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;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tempfile::TempDir;
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_with_remote_env(&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");
let override_md = cwd.join("AGENTS.override.md");
fs.write_file(&agents_md, b"base doc".to_vec(), /*sandbox*/ None)
.await?;
fs.write_file(
&override_md,
b"override doc".to_vec(),
/*sandbox*/ None,
)
.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");
let fallback = cwd.join("WORKFLOW.md");
fs.create_directory(
&agents_dir,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
fs.write_file(&fallback, b"fallback doc".to_vec(), /*sandbox*/ None)
.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");
})
.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");
let git_marker = root.join(".git");
let nested_agents = nested.join("AGENTS.md");
fs.create_directory(
&nested,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
fs.write_file(&root_agents, b"root doc".to_vec(), /*sandbox*/ None)
.await?;
fs.write_file(
&git_marker,
b"gitdir: /tmp/mock-git-dir\n".to_vec(),
/*sandbox*/ None,
)
.await?;
fs.write_file(&nested_agents, b"child doc".to_vec(), /*sandbox*/ None)
.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(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> {
let server = start_mock_server().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let mut builder = test_codex()
.with_config(|config| {
config.cwd = config.cwd.join("logical-repo/workspace");
})
.with_workspace_setup(|cwd, _fs| async move {
// Construct two sibling repositories with the configured cwd as a
// directory symlink from the logical repository into the physical
// repository:
//
// test-root/
// |-- logical-repo/
// | |-- .git
// | |-- AGENTS.md ("logical parent doc")
// | `-- workspace ------------> physical-repo/workspace/
// `-- physical-repo/
// |-- .git
// |-- AGENTS.md ("physical parent doc")
// `-- workspace/
// `-- AGENTS.md ("workspace doc")
//
// Discovery should walk the lexical path through logical-repo,
// while opening logical-repo/workspace/AGENTS.md still follows the
// symlink into physical-repo/workspace.
let logical_root = cwd.parent().expect("symlink should have a parent");
let test_root = logical_root
.parent()
.expect("logical repository should have a parent");
let physical_root = test_root.join("physical-repo");
let physical_workspace = physical_root.join("workspace");
std::fs::create_dir_all(logical_root.as_path())?;
std::fs::write(logical_root.join(".git"), "")?;
std::fs::write(logical_root.join("AGENTS.md"), "logical parent doc")?;
std::fs::create_dir_all(physical_workspace.as_path())?;
std::fs::write(physical_root.join(".git"), "")?;
std::fs::write(physical_root.join("AGENTS.md"), "physical parent doc")?;
std::fs::write(physical_workspace.join("AGENTS.md"), "workspace doc")?;
create_directory_symlink(physical_workspace.as_path(), cwd.as_path());
Ok(())
});
let test = builder.build(&server).await?;
let logical_root = test
.config
.cwd
.parent()
.expect("symlink should have a parent");
assert_eq!(
test.codex.instruction_sources().await,
vec![
logical_root.join("AGENTS.md"),
test.config.cwd.join("AGENTS.md")
]
);
test.submit_turn("hello").await?;
let instructions = resp_mock
.single_request()
.message_input_texts("user")
.into_iter()
.find(|text| text.starts_with("# AGENTS.md instructions for "))
.expect("instructions message");
assert!(instructions.contains("logical parent doc"));
assert!(instructions.contains("workspace doc"));
assert!(!instructions.contains("physical parent doc"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> {
let server = start_mock_server().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let home = Arc::new(TempDir::new()?);
let global_agents = home.path().join("AGENTS.md");
std::fs::write(&global_agents, "global doc")?;
let mut builder = test_codex()
.with_home(home)
.with_workspace_setup(|cwd, fs| async move {
fs.write_file(
&cwd.join("AGENTS.md"),
b"project doc".to_vec(),
/*sandbox*/ None,
)
.await?;
Ok::<(), anyhow::Error>(())
});
let test = builder.build_with_remote_env(&server).await?;
let project_agents = test.config.cwd.join("AGENTS.md");
let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path");
assert_eq!(
test.codex.instruction_sources().await,
vec![global_agents, project_agents]
);
test.submit_turn("hello").await?;
let instructions = resp_mock
.single_request()
.message_input_texts("user")
.into_iter()
.find(|text| text.starts_with("# AGENTS.md instructions for "))
.expect("instructions message");
assert!(instructions.contains("global doc\n\n--- project-doc ---\n\nproject doc"));
Ok(())
}