From cfef81b6e9b14f59d110a8e6c2ebd04b95a55a79 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 18:31:15 +0000 Subject: [PATCH 01/23] Route AGENTS.md loading through environment filesystems --- codex-rs/core/src/agents_md.rs | 178 +++++++++++------- codex-rs/core/src/agents_md_tests.rs | 140 +++++++++++--- codex-rs/core/src/config/config_tests.rs | 26 ++- codex-rs/core/src/config/mod.rs | 8 +- codex-rs/core/src/guardian/review_session.rs | 3 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 13 +- codex-rs/core/src/session/session.rs | 6 +- codex-rs/core/src/session/turn_context.rs | 6 +- .../src/tools/handlers/multi_agents_tests.rs | 6 +- codex-rs/core/tests/common/lib.rs | 9 + codex-rs/core/tests/suite/client.rs | 7 +- .../core/tests/suite/compact_remote_parity.rs | 3 +- codex-rs/core/tests/suite/prompt_caching.rs | 15 +- .../core/tests/suite/prompt_debug_tests.rs | 3 +- 15 files changed, 298 insertions(+), 126 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 8048552480f..b776760e225 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,7 +23,6 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; -use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; @@ -37,8 +36,8 @@ pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; /// Preferred local override for AGENTS.md instructions. pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; -/// When both `Config::instructions` and AGENTS.md docs are present, they will -/// be concatenated with the following separator. +/// When both user and project AGENTS.md docs are present, they will be +/// concatenated with the following separator. const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Resolves AGENTS.md files into model-visible user instructions and source @@ -47,11 +46,6 @@ pub struct AgentsMdManager<'a> { config: &'a Config, } -pub(crate) struct LoadedAgentsMd { - pub(crate) contents: String, - pub(crate) path: AbsolutePathBuf, -} - impl<'a> AgentsMdManager<'a> { pub fn new(config: &'a Config) -> Self { Self { config } @@ -81,10 +75,7 @@ impl<'a> AgentsMdManager<'a> { let contents = String::from_utf8_lossy(&data); let trimmed = contents.trim(); if !trimmed.is_empty() { - return Some(LoadedAgentsMd { - contents: trimmed.to_string(), - path, - }); + return Some(LoadedAgentsMd::new(trimmed.to_string(), path)); } } None @@ -94,10 +85,10 @@ impl<'a> AgentsMdManager<'a> { /// single model-visible instruction string. pub(crate) async fn user_instructions( &self, - environment: Option<&Environment>, + environment: &Environment, startup_warnings: &mut Vec, - ) -> Option { - let fs = environment?.get_filesystem(); + ) -> Option { + let fs = environment.get_filesystem(); self.user_instructions_with_fs(fs.as_ref(), startup_warnings) .await } @@ -106,22 +97,13 @@ impl<'a> AgentsMdManager<'a> { &self, fs: &dyn ExecutorFileSystem, startup_warnings: &mut Vec, - ) -> Option { + ) -> Option { let agents_md_docs = self.read_agents_md(fs, startup_warnings).await; - let mut output = String::new(); - - if let Some(instructions) = self.config.user_instructions.clone() { - output.push_str(&instructions); - } + let mut loaded = self.config.user_instructions.clone().unwrap_or_default(); match agents_md_docs { - Ok(Some(docs)) => { - if !output.is_empty() { - output.push_str(AGENTS_MD_SEPARATOR); - } - output.push_str(&docs); - } + Ok(Some(docs)) => loaded.append_project_docs(docs), Ok(None) => {} Err(e) => { error!("error trying to find AGENTS.md docs: {e:#}"); @@ -129,50 +111,26 @@ impl<'a> AgentsMdManager<'a> { }; if self.config.features.enabled(Feature::ChildAgentsMd) { - if !output.is_empty() { - output.push_str("\n\n"); - } - output.push_str(HIERARCHICAL_AGENTS_MESSAGE); + loaded.instructions.push(LoadedInstruction { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + source: InstructionSource::Internal, + }); } - if !output.is_empty() { - Some(output) - } else { - None - } - } - - /// Returns all instruction source files included in the current config. - pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut global_instruction_warnings = Vec::new(); - let mut paths = Self::load_global_instructions( - LOCAL_FS.as_ref(), - Some(&self.config.codex_home), - &mut global_instruction_warnings, - ) - .await - .map(|loaded| vec![loaded.path]) - .unwrap_or_default(); - match self.agents_md_paths(fs).await { - Ok(agents_md_paths) => paths.extend(agents_md_paths), - Err(err) => { - tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); - } - } - paths + (!loaded.is_empty()).then_some(loaded) } /// Attempt to locate and load AGENTS.md documentation. /// - /// On success returns `Ok(Some(contents))` where `contents` is 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 callers can decide how to handle them. + /// On success returns `Ok(Some(loaded))` where `loaded` contains every + /// discovered doc. If no documentation file is found the function returns + /// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can + /// decide how to handle them. async fn read_agents_md( &self, fs: &dyn ExecutorFileSystem, startup_warnings: &mut Vec, - ) -> io::Result> { + ) -> io::Result> { let max_total = self.config.project_doc_max_bytes; if max_total == 0 { @@ -185,7 +143,7 @@ impl<'a> AgentsMdManager<'a> { } let mut remaining: u64 = max_total as u64; - let mut parts: Vec = Vec::new(); + let mut loaded = LoadedAgentsMd::default(); for p in paths { if remaining == 0 { @@ -221,15 +179,18 @@ impl<'a> AgentsMdManager<'a> { let text = String::from_utf8_lossy(&data).to_string(); if !text.trim().is_empty() { - parts.push(text); + loaded.instructions.push(LoadedInstruction { + contents: text, + source: InstructionSource::Project(p), + }); remaining = remaining.saturating_sub(data.len() as u64); } } - if parts.is_empty() { + if loaded.is_empty() { Ok(None) } else { - Ok(Some(parts.join("\n\n"))) + Ok(Some(loaded)) } } @@ -348,6 +309,93 @@ impl<'a> AgentsMdManager<'a> { } } +/// Model-visible instructions loaded from AGENTS.md files and internal +/// guidance. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LoadedAgentsMd { + /// Ordered instructions and their provenance. + instructions: Vec, +} + +impl LoadedAgentsMd { + /// Creates loaded instructions containing one user-level AGENTS.md entry. + pub fn new(contents: String, path: AbsolutePathBuf) -> Self { + Self { + instructions: vec![LoadedInstruction { + contents, + source: InstructionSource::User(path), + }], + } + } + + fn append_project_docs(&mut self, project_docs: Self) { + self.instructions.extend(project_docs.instructions); + } + + fn is_empty(&self) -> bool { + self.instructions.is_empty() + } + + /// Returns the concatenated model-visible instruction text. + pub fn text(&self) -> String { + let mut output = String::new(); + let mut previous_source = None; + for instruction in &self.instructions { + if let Some(previous_source) = previous_source { + output.push_str(instruction.source.separator_after(previous_source)); + } + output.push_str(&instruction.contents); + previous_source = Some(&instruction.source); + } + output + } + + /// Returns the AGENTS.md files that supplied instruction entries. + pub fn sources(&self) -> impl Iterator { + self.instructions + .iter() + .filter_map(|instruction| instruction.source.path()) + } +} + +/// One model-visible instruction and its provenance. +#[derive(Clone, Debug, PartialEq, Eq)] +struct LoadedInstruction { + /// Model-visible instruction text. + contents: String, + + /// Origin of the instruction. + source: InstructionSource, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum InstructionSource { + /// User-level instructions, normally loaded from CODEX_HOME. + User(AbsolutePathBuf), + + /// Workspace instructions discovered from project AGENTS.md files. + Project(AbsolutePathBuf), + + /// Instructions defined internally by Codex. + Internal, +} + +impl InstructionSource { + fn separator_after(&self, previous: &Self) -> &'static str { + match (previous, self) { + (Self::User(_), Self::Project(_)) => AGENTS_MD_SEPARATOR, + _ => "\n\n", + } + } + + fn path(&self) -> Option<&AbsolutePathBuf> { + match self { + Self::User(path) | Self::Project(path) => Some(path), + Self::Internal => None, + } + } +} + fn warn_invalid_utf8( path: &AbsolutePathBuf, data: &[u8], diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 2d6a5fa30b5..0e1cbcfc872 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -16,6 +16,7 @@ async fn get_user_instructions(config: &Config) -> Option { AgentsMdManager::new(config) .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await + .map(|loaded| loaded.text()) } async fn agents_md_paths(config: &Config) -> std::io::Result> { @@ -53,7 +54,12 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(|text| { + LoadedAgentsMd::new( + text.to_owned(), + config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), + ) + }); config } @@ -96,7 +102,12 @@ async fn make_config_with_project_root_markers( config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(|text| { + LoadedAgentsMd::new( + text.to_owned(), + config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), + ) + }); config } @@ -115,19 +126,6 @@ async fn no_doc_file_returns_none() { 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 mut warnings = Vec::new(); - let res = AgentsMdManager::new(&config) - .user_instructions(/*environment*/ None, &mut warnings) - .await; - - assert_eq!(res, None); -} - /// Small file within the byte-limit is returned unmodified. #[tokio::test] async fn doc_smaller_than_limit_is_returned() { @@ -161,8 +159,10 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() { .await .expect("global doc expected"); - assert_eq!(loaded.contents, "global\u{FFFD} doc"); - assert_eq!(loaded.path, path); + assert_eq!( + loaded, + LoadedAgentsMd::new("global\u{FFFD} doc".to_string(), path.clone()) + ); assert_invalid_utf8_warning(&warnings, "Global", path.as_path()); } @@ -177,7 +177,8 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { let res = AgentsMdManager::new(&config) .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await - .expect("doc expected"); + .expect("doc expected") + .text(); assert_eq!(res, "project\u{FFFD} doc"); let canonical_path = dunce::canonicalize(&path).expect("canonical doc path"); @@ -310,8 +311,38 @@ async fn concatenates_root_and_cwd_docs() { let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await; cfg.cwd = nested.abs(); - let res = get_user_instructions(&cfg).await.expect("doc expected"); - assert_eq!(res, "root doc\n\ncrate doc"); + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("doc expected"); + let root_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"), + ) + .expect("absolute root doc path"); + let crate_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"), + ) + .expect("absolute crate doc path"); + let expected = LoadedAgentsMd { + instructions: vec![ + LoadedInstruction { + contents: "root doc".to_string(), + source: InstructionSource::Project(root_agents.clone()), + }, + LoadedInstruction { + contents: "crate doc".to_string(), + source: InstructionSource::Project(crate_agents.clone()), + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!(loaded.text(), "root doc\n\ncrate doc"); + assert_eq!( + loaded.sources().collect::>(), + vec![&root_agents, &crate_agents] + ); } #[tokio::test] @@ -350,25 +381,84 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn child_agents_message_after_global_instructions_uses_plain_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + let expected = LoadedAgentsMd { + instructions: vec![ + LoadedInstruction { + contents: "global doc".to_string(), + source: InstructionSource::User(global_agents), + }, + LoadedInstruction { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + source: InstructionSource::Internal, + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!( + loaded.text(), + format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") + ); +} + #[tokio::test] async fn instruction_sources_include_global_before_agents_md_docs() { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); - let cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); fs::create_dir_all(&cfg.codex_home).unwrap(); fs::write(&global_agents, "global doc").unwrap(); - let sources = AgentsMdManager::new(&cfg) - .instruction_sources(LOCAL_FS.as_ref()) - .await; + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); let project_agents = AbsolutePathBuf::try_from( dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), ) .expect("absolute project doc path"); - assert_eq!(sources, vec![global_agents, project_agents]); + let expected = LoadedAgentsMd { + instructions: vec![ + LoadedInstruction { + contents: "global doc".to_string(), + source: InstructionSource::User(global_agents.clone()), + }, + LoadedInstruction { + contents: "project doc".to_string(), + source: InstructionSource::Project(project_agents.clone()), + }, + LoadedInstruction { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + source: InstructionSource::Internal, + }, + ], + }; + assert_eq!(loaded, expected); + assert_eq!( + loaded.sources().collect::>(), + vec![&global_agents, &project_agents] + ); + assert_eq!( + loaded.text(), + format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") + ); } /// AGENTS.override.md is preferred over AGENTS.md when both are present. diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dac8286749f..e8b4c57b056 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -208,10 +208,8 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { #[tokio::test] async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { let codex_home = tempdir()?; - std::fs::write( - codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), - "\n global instructions \n", - )?; + let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME); + std::fs::write(&global_agents_path, "\n global instructions \n")?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), @@ -221,9 +219,14 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { .await?; let _ = config.features.enable(Feature::MemoryTool); + let user_instructions = config + .user_instructions + .as_ref() + .expect("global instructions expected"); + assert_eq!(user_instructions.text(), "global instructions"); assert_eq!( - config.user_instructions.as_deref(), - Some("global instructions") + user_instructions.sources().collect::>(), + vec![&global_agents_path] ); Ok(()) } @@ -235,7 +238,7 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), "global instructions", )?; - let global_agents_override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME); + let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME); std::fs::write(&global_agents_override_path, "local override instructions")?; let config = Config::load_from_base_config_with_overrides( @@ -245,9 +248,14 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R ) .await?; + let user_instructions = config + .user_instructions + .as_ref() + .expect("global override instructions expected"); + assert_eq!(user_instructions.text(), "local override instructions"); assert_eq!( - config.user_instructions.as_deref(), - Some("local override instructions") + user_instructions.sources().collect::>(), + vec![&global_agents_override_path] ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2c3cd4d6c33..a118680a66b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,4 +1,5 @@ use crate::agents_md::AgentsMdManager; +use crate::agents_md::LoadedAgentsMd; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::path_utils::normalize_for_native_workdir; @@ -643,8 +644,8 @@ pub struct Config { /// Defaults to `false`. pub show_raw_agent_reasoning: bool, - /// User-provided instructions from AGENTS.md. - pub user_instructions: Option, + /// User-provided instructions loaded from AGENTS.md. + pub user_instructions: Option, /// Base instructions override. pub base_instructions: Option, @@ -2578,8 +2579,7 @@ impl Config { Some(&codex_home), &mut startup_warnings, ) - .await - .map(|loaded| loaded.contents); + .await; // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index c139a910a75..0de607de985 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -29,6 +29,7 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use tracing::warn; +use crate::LoadedAgentsMd; use crate::codex_delegate::run_codex_thread_interactive; use crate::config::Config; use crate::config::Constrained; @@ -146,7 +147,7 @@ struct GuardianReviewSessionReuseKey { permissions: Permissions, developer_instructions: Option, base_instructions: Option, - user_instructions: Option, + user_instructions: Option, compact_prompt: Option, cwd: AbsolutePathBuf, mcp_servers: Constrained>, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 10b3511d5d4..b77f96cdb1a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -131,6 +131,7 @@ pub(crate) mod agents_md; pub use agents_md::AgentsMdManager; pub use agents_md::DEFAULT_AGENTS_MD_FILENAME; pub use agents_md::LOCAL_AGENTS_MD_FILENAME; +pub use agents_md::LoadedAgentsMd; mod rollout; pub(crate) mod safety; mod session_rollout_init_error; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3ea33a6bde3..2955cb70842 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -507,12 +507,13 @@ impl Codex { let primary_environment = environment_selections.primary_environment(); let mut user_instruction_warnings = Vec::new(); - let user_instructions = AgentsMdManager::new(&config) - .user_instructions( - primary_environment.as_deref(), - &mut user_instruction_warnings, - ) - .await; + let user_instructions = if let Some(primary_environment) = primary_environment { + AgentsMdManager::new(&config) + .user_instructions(primary_environment.as_ref(), &mut user_instruction_warnings) + .await + } else { + None + }; config.startup_warnings.extend(user_instruction_warnings); let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 07d249613ac..466b89f8999 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,6 @@ use super::input_queue::InputQueue; use super::*; +use crate::agents_md::LoadedAgentsMd; use crate::config::ConstraintError; use crate::goals::GoalRuntimeState; use crate::skills::SkillError; @@ -54,8 +55,9 @@ pub(crate) struct SessionConfiguration { /// Developer instructions that supplement the base instructions. pub(super) developer_instructions: Option, - /// Model instructions that are appended to the base instructions. - pub(super) user_instructions: Option, + /// Model instructions that are appended to the base instructions and the + /// files that supplied them. + pub(super) user_instructions: Option, /// Personality preference for the model. pub(super) personality: Option, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index fea422cb4bb..e62e4471555 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,5 +1,6 @@ use super::*; use crate::SkillLoadOutcome; +use crate::agents_md::LoadedAgentsMd; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_core_skills::HostLoadedSkills; @@ -551,7 +552,10 @@ impl Session { app_server_client_name: session_configuration.app_server_client_name.clone(), developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), - user_instructions: session_configuration.user_instructions.clone(), + user_instructions: session_configuration + .user_instructions + .as_ref() + .map(LoadedAgentsMd::text), collaboration_mode: session_configuration.collaboration_mode.clone(), multi_agent_version, personality: session_configuration.personality, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index d32bd5dd62b..e9acb46797d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::LoadedAgentsMd; use crate::ThreadManager; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; @@ -4399,7 +4400,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() { async fn build_agent_spawn_config_preserves_base_user_instructions() { let (_session, mut turn) = make_session_and_context().await; let mut base_config = (*turn.config).clone(); - base_config.user_instructions = Some("base-user".to_string()); + base_config.user_instructions = Some(LoadedAgentsMd::new( + "base-user".to_string(), + base_config.codex_home.join("AGENTS.md"), + )); turn.user_instructions = Some("resolved-user".to_string()); turn.config = Arc::new(base_config.clone()); let base_instructions = BaseInstructions { diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index be07b803f85..d18d15aa76c 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -12,6 +12,7 @@ use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::test_support::CloudConfigBundleFixture; use codex_core::CodexThread; +use codex_core::LoadedAgentsMd; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -32,6 +33,14 @@ pub mod test_codex_exec; pub mod tracing; pub mod zsh_fork; +/// Creates loaded user instructions with a stable synthetic AGENTS.md source path. +pub fn loaded_instructions(text: impl Into) -> LoadedAgentsMd { + LoadedAgentsMd::new( + text.into(), + AbsolutePathBuf::from_absolute_path("/tmp/test-AGENTS.md").expect("absolute path"), + ) +} + static TEST_ARG0_PATH_ENTRY: OnceLock> = OnceLock::new(); #[ctor] diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c954a954fb3..9b9fd7cb614 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -51,6 +51,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; +use core_test_support::loaded_instructions; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -364,7 +365,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .with_home(codex_home.clone()) .with_config(|config| { // Ensure user instructions are NOT delivered on resume. - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(loaded_instructions("be nice")); }); let test = builder .resume(&server, codex_home, session_path.clone()) @@ -1180,7 +1181,7 @@ async fn includes_user_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(loaded_instructions("be nice")); }); let codex = builder .build(&server) @@ -2212,7 +2213,7 @@ async fn includes_developer_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(loaded_instructions("be nice")); config.developer_instructions = Some("be useful".to_string()); }); let codex = builder diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 22678fc46f7..2f5dcde689e 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -14,6 +14,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use core_test_support::hooks::trust_discovered_hooks; +use core_test_support::loaded_instructions; use core_test_support::responses; use core_test_support::responses::ResponseMock; use core_test_support::skip_if_no_network; @@ -517,7 +518,7 @@ async fn build_harness_inner( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some("PARITY_USER_INSTRUCTIONS".to_string()); + config.user_instructions = Some(loaded_instructions("PARITY_USER_INSTRUCTIONS")); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index b9afa13852f..e77f9ff512b 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; +use core_test_support::loaded_instructions; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -122,7 +123,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(loaded_instructions("be consistent and helpful")); config.model = Some("gpt-5.2".to_string()); // Keep tool expectations stable when the default web_search mode changes. config @@ -237,7 +238,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(loaded_instructions("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -319,7 +320,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(loaded_instructions("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -417,7 +418,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(loaded_instructions("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -709,7 +710,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(loaded_instructions("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -844,7 +845,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(loaded_instructions("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -985,7 +986,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(loaded_instructions("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index dc506bc4746..2d07cb995a9 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -5,6 +5,7 @@ use codex_core::config::ConfigOverrides; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; +use core_test_support::loaded_instructions; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -21,7 +22,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; - config.user_instructions = Some("Project-specific test instructions".to_string()); + config.user_instructions = Some(loaded_instructions("Project-specific test instructions")); let input = build_prompt_input( config, From 51f8c65248f61301d8ff4e57eae02000fc1ebe07 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 18:38:00 +0000 Subject: [PATCH 02/23] Expose source-less loaded user instructions --- codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/src/agents_md.rs | 14 ++++++++++++-- codex-rs/core/src/agents_md_tests.rs | 25 +++++++++++++++++++++++++ codex-rs/core/src/config/mod.rs | 4 ++-- codex-rs/core/tests/common/lib.rs | 7 ++----- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index e87ee82f309..d1c7c7a18b0 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -26,6 +26,7 @@ pub use codex_config::types::TuiPetAnchor; pub use codex_config::types::UriBasedFileOpener; pub use codex_core::CodexThread; pub use codex_core::ForkSnapshot; +pub use codex_core::LoadedAgentsMd; pub use codex_core::McpManager; pub use codex_core::NewThread; pub use codex_core::StartThreadOptions; diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index b776760e225..bfe4176acb3 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -328,6 +328,16 @@ impl LoadedAgentsMd { } } + /// Creates source-less user instructions. + pub fn from_text(contents: String) -> Self { + Self { + instructions: vec![LoadedInstruction { + contents, + source: InstructionSource::Internal, + }], + } + } + fn append_project_docs(&mut self, project_docs: Self) { self.instructions.extend(project_docs.instructions); } @@ -376,14 +386,14 @@ enum InstructionSource { /// Workspace instructions discovered from project AGENTS.md files. Project(AbsolutePathBuf), - /// Instructions defined internally by Codex. + /// Instructions without a file source, including internally defined guidance. Internal, } impl InstructionSource { fn separator_after(&self, previous: &Self) -> &'static str { match (previous, self) { - (Self::User(_), Self::Project(_)) => AGENTS_MD_SEPARATOR, + (Self::User(_) | Self::Internal, Self::Project(_)) => AGENTS_MD_SEPARATOR, _ => "\n\n", } } diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 0e1cbcfc872..be0193b0a71 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -273,6 +273,31 @@ async fn merges_existing_instructions_with_agents_md() { assert_eq!(res, expected); } +#[tokio::test] +async fn source_less_user_instructions_preserve_separator_without_reporting_a_source() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.user_instructions = Some(LoadedAgentsMd::from_text("user instructions".to_string())); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), + ) + .expect("absolute project doc path"); + + assert_eq!( + loaded.text(), + format!("user instructions{AGENTS_MD_SEPARATOR}project doc") + ); + assert_eq!(loaded.sources().collect::>(), vec![&project_agents]); +} + /// If there are existing system instructions but AGENTS.md docs are /// missing we expect the original instructions to be returned unchanged. #[tokio::test] diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a118680a66b..70d2eea71f6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,5 +1,5 @@ use crate::agents_md::AgentsMdManager; -use crate::agents_md::LoadedAgentsMd; +pub use crate::agents_md::LoadedAgentsMd; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::path_utils::normalize_for_native_workdir; @@ -644,7 +644,7 @@ pub struct Config { /// Defaults to `false`. pub show_raw_agent_reasoning: bool, - /// User-provided instructions loaded from AGENTS.md. + /// User-provided instructions, including those loaded from AGENTS.md. pub user_instructions: Option, /// Base instructions override. diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index d18d15aa76c..6c6d22bb134 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -33,12 +33,9 @@ pub mod test_codex_exec; pub mod tracing; pub mod zsh_fork; -/// Creates loaded user instructions with a stable synthetic AGENTS.md source path. +/// Creates source-less loaded user instructions for integration tests. pub fn loaded_instructions(text: impl Into) -> LoadedAgentsMd { - LoadedAgentsMd::new( - text.into(), - AbsolutePathBuf::from_absolute_path("/tmp/test-AGENTS.md").expect("absolute path"), - ) + LoadedAgentsMd::from_text(text.into()) } static TEST_ARG0_PATH_ENTRY: OnceLock> = OnceLock::new(); From 73dd5749957c852733263646dde188a415c8a07a Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 18:39:39 +0000 Subject: [PATCH 03/23] Restore AGENTS.md source discovery API --- codex-rs/core/src/agents_md.rs | 8 ++++++++ codex-rs/core/src/agents_md_tests.rs | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index bfe4176acb3..0291719f765 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -93,6 +93,14 @@ impl<'a> AgentsMdManager<'a> { .await } + /// Returns the AGENTS.md files that contribute model-visible instructions. + pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { + let mut startup_warnings = Vec::new(); + self.user_instructions_with_fs(fs, &mut startup_warnings) + .await + .map_or_else(Vec::new, |loaded| loaded.sources().cloned().collect()) + } + async fn user_instructions_with_fs( &self, fs: &dyn ExecutorFileSystem, diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index be0193b0a71..989a4cc4fce 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -480,6 +480,12 @@ async fn instruction_sources_include_global_before_agents_md_docs() { loaded.sources().collect::>(), vec![&global_agents, &project_agents] ); + assert_eq!( + AgentsMdManager::new(&cfg) + .instruction_sources(LOCAL_FS.as_ref()) + .await, + vec![global_agents, project_agents] + ); assert_eq!( loaded.text(), format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") From 18ad9cb1c893f587ebedfb5d5f8925ee638ab009 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 18:41:38 +0000 Subject: [PATCH 04/23] Treat empty loaded instructions as absent --- codex-rs/core/src/agents_md.rs | 6 ++++++ codex-rs/core/src/agents_md_tests.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 0291719f765..f59417a1feb 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -328,6 +328,9 @@ pub struct LoadedAgentsMd { impl LoadedAgentsMd { /// Creates loaded instructions containing one user-level AGENTS.md entry. pub fn new(contents: String, path: AbsolutePathBuf) -> Self { + if contents.is_empty() { + return Self::default(); + } Self { instructions: vec![LoadedInstruction { contents, @@ -338,6 +341,9 @@ impl LoadedAgentsMd { /// Creates source-less user instructions. pub fn from_text(contents: String) -> Self { + if contents.is_empty() { + return Self::default(); + } Self { instructions: vec![LoadedInstruction { contents, diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 989a4cc4fce..be608e0a961 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -126,6 +126,21 @@ async fn no_doc_file_returns_none() { assert!(res.is_none(), "Expected None when AGENTS.md is absent"); } +#[test] +fn empty_loaded_instructions_are_empty() { + let source = + AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path"); + + assert_eq!( + LoadedAgentsMd::new(String::new(), source), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::from_text(String::new()), + LoadedAgentsMd::default() + ); +} + /// Small file within the byte-limit is returned unmodified. #[tokio::test] async fn doc_smaller_than_limit_is_returned() { From dcd6d1e6685712922ee559f6d834a12807cf3fad Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 20:22:58 +0000 Subject: [PATCH 05/23] Use LoadedAgentsMd directly in integration tests --- codex-rs/core/src/agents_md.rs | 3 ++- codex-rs/core/tests/common/lib.rs | 6 ------ codex-rs/core/tests/suite/client.rs | 8 ++++---- .../core/tests/suite/compact_remote_parity.rs | 4 ++-- codex-rs/core/tests/suite/prompt_caching.rs | 16 ++++++++-------- codex-rs/core/tests/suite/prompt_debug_tests.rs | 6 ++++-- 6 files changed, 20 insertions(+), 23 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index f59417a1feb..bf59248303f 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -340,7 +340,8 @@ impl LoadedAgentsMd { } /// Creates source-less user instructions. - pub fn from_text(contents: String) -> Self { + pub fn from_text(contents: impl Into) -> Self { + let contents = contents.into(); if contents.is_empty() { return Self::default(); } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 6c6d22bb134..be07b803f85 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -12,7 +12,6 @@ use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::test_support::CloudConfigBundleFixture; use codex_core::CodexThread; -use codex_core::LoadedAgentsMd; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -33,11 +32,6 @@ pub mod test_codex_exec; pub mod tracing; pub mod zsh_fork; -/// Creates source-less loaded user instructions for integration tests. -pub fn loaded_instructions(text: impl Into) -> LoadedAgentsMd { - LoadedAgentsMd::from_text(text.into()) -} - static TEST_ARG0_PATH_ENTRY: OnceLock> = OnceLock::new(); #[ctor] diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 9b9fd7cb614..e47c368676c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,5 +1,6 @@ use codex_config::ConfigLayerStack; use codex_config::types::AuthCredentialsStoreMode; +use codex_core::LoadedAgentsMd; use codex_core::ModelClient; use codex_core::NewThread; use codex_core::Prompt; @@ -51,7 +52,6 @@ use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; -use core_test_support::loaded_instructions; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -365,7 +365,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .with_home(codex_home.clone()) .with_config(|config| { // Ensure user instructions are NOT delivered on resume. - config.user_instructions = Some(loaded_instructions("be nice")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be nice")); }); let test = builder .resume(&server, codex_home, session_path.clone()) @@ -1181,7 +1181,7 @@ async fn includes_user_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be nice")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be nice")); }); let codex = builder .build(&server) @@ -2213,7 +2213,7 @@ async fn includes_developer_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be nice")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be nice")); config.developer_instructions = Some("be useful".to_string()); }); let codex = builder diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 2f5dcde689e..228934e9f12 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -5,6 +5,7 @@ use std::path::Path; use std::path::PathBuf; use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ServiceTier; @@ -14,7 +15,6 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use core_test_support::hooks::trust_discovered_hooks; -use core_test_support::loaded_instructions; use core_test_support::responses; use core_test_support::responses::ResponseMock; use core_test_support::skip_if_no_network; @@ -518,7 +518,7 @@ async fn build_harness_inner( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some(loaded_instructions("PARITY_USER_INSTRUCTIONS")); + config.user_instructions = Some(LoadedAgentsMd::from_text("PARITY_USER_INSTRUCTIONS")); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index e77f9ff512b..9cbaf37a880 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::LoadedAgentsMd; use codex_core::shell::default_user_shell; use codex_features::Feature; use codex_prompts::APPLY_PATCH_TOOL_INSTRUCTIONS; @@ -17,7 +18,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; -use core_test_support::loaded_instructions; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -123,7 +123,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); config.model = Some("gpt-5.2".to_string()); // Keep tool expectations stable when the default web_search mode changes. config @@ -238,7 +238,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -320,7 +320,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -418,7 +418,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -710,7 +710,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -845,7 +845,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) @@ -986,7 +986,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(loaded_instructions("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); config .features .enable(Feature::CollaborationModes) diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 2d07cb995a9..744dbff1b88 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -1,11 +1,11 @@ use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_core::build_prompt_input; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; -use core_test_support::loaded_instructions; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -22,7 +22,9 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; - config.user_instructions = Some(loaded_instructions("Project-specific test instructions")); + config.user_instructions = Some(LoadedAgentsMd::from_text( + "Project-specific test instructions", + )); let input = build_prompt_input( config, From a4ea9ffbea395f611a3d8fac99b4e098f5207e0c Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 20:23:26 +0000 Subject: [PATCH 06/23] Separate child AGENTS.md source coverage --- codex-rs/core/src/agents_md_tests.rs | 55 +++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index be608e0a961..0822620a874 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -458,8 +458,7 @@ async fn instruction_sources_include_global_before_agents_md_docs() { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); - let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; - cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + let cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); fs::create_dir_all(&cfg.codex_home).unwrap(); fs::write(&global_agents, "global doc").unwrap(); @@ -484,10 +483,6 @@ async fn instruction_sources_include_global_before_agents_md_docs() { contents: "project doc".to_string(), source: InstructionSource::Project(project_agents.clone()), }, - LoadedInstruction { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - source: InstructionSource::Internal, - }, ], }; assert_eq!(loaded, expected); @@ -501,6 +496,54 @@ async fn instruction_sources_include_global_before_agents_md_docs() { .await, vec![global_agents, project_agents] ); + assert_eq!( + loaded.text(), + format!("global doc{AGENTS_MD_SEPARATOR}project doc") + ); +} + +#[tokio::test] +async fn child_agents_message_after_project_docs_is_not_an_instruction_source() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + fs::create_dir_all(&cfg.codex_home).unwrap(); + fs::write(&global_agents, "global doc").unwrap(); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), + ) + .expect("absolute project doc path"); + + let expected = LoadedAgentsMd { + instructions: vec![ + LoadedInstruction { + contents: "global doc".to_string(), + source: InstructionSource::User(global_agents.clone()), + }, + LoadedInstruction { + contents: "project doc".to_string(), + source: InstructionSource::Project(project_agents.clone()), + }, + LoadedInstruction { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + source: InstructionSource::Internal, + }, + ], + }; + assert_eq!(loaded, expected); + assert_eq!( + loaded.sources().collect::>(), + vec![&global_agents, &project_agents] + ); assert_eq!( loaded.text(), format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") From d847029eda9b1b109cbef5501edc0d23c4f8499a Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 20:51:10 +0000 Subject: [PATCH 07/23] Name the user AGENTS.md constructor explicitly --- codex-rs/core/src/agents_md.rs | 4 ++-- codex-rs/core/src/agents_md_tests.rs | 8 ++++---- codex-rs/core/src/tools/handlers/multi_agents_tests.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index bf59248303f..0ede8594c94 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -75,7 +75,7 @@ impl<'a> AgentsMdManager<'a> { let contents = String::from_utf8_lossy(&data); let trimmed = contents.trim(); if !trimmed.is_empty() { - return Some(LoadedAgentsMd::new(trimmed.to_string(), path)); + return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path)); } } None @@ -327,7 +327,7 @@ pub struct LoadedAgentsMd { impl LoadedAgentsMd { /// Creates loaded instructions containing one user-level AGENTS.md entry. - pub fn new(contents: String, path: AbsolutePathBuf) -> Self { + pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self { if contents.is_empty() { return Self::default(); } diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 0822620a874..13685cf8ea3 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -55,7 +55,7 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(|text| { - LoadedAgentsMd::new( + LoadedAgentsMd::new_user( text.to_owned(), config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), ) @@ -103,7 +103,7 @@ async fn make_config_with_project_root_markers( config.cwd = root.abs(); config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(|text| { - LoadedAgentsMd::new( + LoadedAgentsMd::new_user( text.to_owned(), config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), ) @@ -132,7 +132,7 @@ fn empty_loaded_instructions_are_empty() { AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path"); assert_eq!( - LoadedAgentsMd::new(String::new(), source), + LoadedAgentsMd::new_user(String::new(), source), LoadedAgentsMd::default() ); assert_eq!( @@ -176,7 +176,7 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() { assert_eq!( loaded, - LoadedAgentsMd::new("global\u{FFFD} doc".to_string(), path.clone()) + LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone()) ); assert_invalid_utf8_warning(&warnings, "Global", path.as_path()); } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e9acb46797d..6eec6fa2ed8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -4400,7 +4400,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { async fn build_agent_spawn_config_preserves_base_user_instructions() { let (_session, mut turn) = make_session_and_context().await; let mut base_config = (*turn.config).clone(); - base_config.user_instructions = Some(LoadedAgentsMd::new( + base_config.user_instructions = Some(LoadedAgentsMd::new_user( "base-user".to_string(), base_config.codex_home.join("AGENTS.md"), )); From eb4eca29df8068f31642eb2ecad6f47a10999cca Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 20:52:13 +0000 Subject: [PATCH 08/23] Mark source-less AGENTS.md construction as test-only --- codex-rs/core/src/agents_md.rs | 7 +++-- codex-rs/core/src/agents_md_tests.rs | 6 ++-- codex-rs/core/tests/suite/client.rs | 6 ++-- .../core/tests/suite/compact_remote_parity.rs | 4 ++- codex-rs/core/tests/suite/prompt_caching.rs | 28 ++++++++++++++----- .../core/tests/suite/prompt_debug_tests.rs | 2 +- 6 files changed, 37 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 0ede8594c94..3882f2c1303 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -339,8 +339,11 @@ impl LoadedAgentsMd { } } - /// Creates source-less user instructions. - pub fn from_text(contents: impl Into) -> Self { + /// Creates source-less user instructions for tests. + /// + /// This cannot be gated with `#[cfg(test)]` because integration tests + /// compile `codex-core` as a normal dependency without that configuration. + pub fn from_text_for_testing(contents: impl Into) -> Self { let contents = contents.into(); if contents.is_empty() { return Self::default(); diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 13685cf8ea3..0d4003b381c 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -136,7 +136,7 @@ fn empty_loaded_instructions_are_empty() { LoadedAgentsMd::default() ); assert_eq!( - LoadedAgentsMd::from_text(String::new()), + LoadedAgentsMd::from_text_for_testing(String::new()), LoadedAgentsMd::default() ); } @@ -294,7 +294,9 @@ async fn source_less_user_instructions_preserve_separator_without_reporting_a_so fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; - cfg.user_instructions = Some(LoadedAgentsMd::from_text("user instructions".to_string())); + cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "user instructions".to_string(), + )); let mut warnings = Vec::new(); let loaded = AgentsMdManager::new(&cfg) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e47c368676c..f430a5c1ebf 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -365,7 +365,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .with_home(codex_home.clone()) .with_config(|config| { // Ensure user instructions are NOT delivered on resume. - config.user_instructions = Some(LoadedAgentsMd::from_text("be nice")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); }); let test = builder .resume(&server, codex_home, session_path.clone()) @@ -1181,7 +1181,7 @@ async fn includes_user_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be nice")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); }); let codex = builder .build(&server) @@ -2213,7 +2213,7 @@ async fn includes_developer_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be nice")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); config.developer_instructions = Some("be useful".to_string()); }); let codex = builder diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 228934e9f12..395f3158ddc 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -518,7 +518,9 @@ async fn build_harness_inner( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some(LoadedAgentsMd::from_text("PARITY_USER_INSTRUCTIONS")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "PARITY_USER_INSTRUCTIONS", + )); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 9cbaf37a880..2a42bd0a166 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -123,7 +123,9 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config.model = Some("gpt-5.2".to_string()); // Keep tool expectations stable when the default web_search mode changes. config @@ -238,7 +240,9 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -320,7 +324,9 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -418,7 +424,9 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -710,7 +718,9 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -845,7 +855,9 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -986,7 +998,9 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .. } = test_codex() .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text("be consistent and helpful")); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 744dbff1b88..bad7e1ab5aa 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -22,7 +22,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; - config.user_instructions = Some(LoadedAgentsMd::from_text( + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( "Project-specific test instructions", )); From df7f33ba3b5c92275759a729269b0ed25d336678 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 20:52:59 +0000 Subject: [PATCH 09/23] Treat whitespace-only loaded instructions as empty --- codex-rs/core/src/agents_md.rs | 8 +++++--- codex-rs/core/src/agents_md_tests.rs | 29 +++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 3882f2c1303..83bec85f3f8 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -328,7 +328,7 @@ pub struct LoadedAgentsMd { impl LoadedAgentsMd { /// Creates loaded instructions containing one user-level AGENTS.md entry. pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self { - if contents.is_empty() { + if contents.trim().is_empty() { return Self::default(); } Self { @@ -345,7 +345,7 @@ impl LoadedAgentsMd { /// compile `codex-core` as a normal dependency without that configuration. pub fn from_text_for_testing(contents: impl Into) -> Self { let contents = contents.into(); - if contents.is_empty() { + if contents.trim().is_empty() { return Self::default(); } Self { @@ -361,7 +361,9 @@ impl LoadedAgentsMd { } fn is_empty(&self) -> bool { - self.instructions.is_empty() + self.instructions + .iter() + .all(|instruction| instruction.contents.trim().is_empty()) } /// Returns the concatenated model-visible instruction text. diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 0d4003b381c..45e42a92ccf 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -132,13 +132,40 @@ fn empty_loaded_instructions_are_empty() { AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path"); assert_eq!( - LoadedAgentsMd::new_user(String::new(), source), + LoadedAgentsMd::new_user(String::new(), source.clone()), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::new_user(" \n\t".to_string(), source), LoadedAgentsMd::default() ); assert_eq!( LoadedAgentsMd::from_text_for_testing(String::new()), LoadedAgentsMd::default() ); + assert_eq!( + LoadedAgentsMd::from_text_for_testing(" \n\t"), + LoadedAgentsMd::default() + ); +} + +#[test] +fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() { + let empty = LoadedAgentsMd { + instructions: vec![LoadedInstruction { + contents: String::new(), + source: InstructionSource::Internal, + }], + }; + let whitespace = LoadedAgentsMd { + instructions: vec![LoadedInstruction { + contents: " \n\t".to_string(), + source: InstructionSource::Internal, + }], + }; + + assert!(empty.is_empty()); + assert!(whitespace.is_empty()); } /// Small file within the byte-limit is returned unmodified. From 6eab3d80431d2d1c29dd6ca72c878f14d451ff58 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 2 Jun 2026 20:53:15 +0000 Subject: [PATCH 10/23] Explain the project instruction separator boundary --- codex-rs/core/src/agents_md.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 83bec85f3f8..4389c93fbe8 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -412,6 +412,10 @@ enum InstructionSource { impl InstructionSource { fn separator_after(&self, previous: &Self) -> &'static str { + // The project-doc marker tells the model where workspace-scoped + // instructions begin. It belongs only before the first project entry; + // subsequent project docs and trailing internal guidance are part of + // an already established instruction section. match (previous, self) { (Self::User(_) | Self::Internal, Self::Project(_)) => AGENTS_MD_SEPARATOR, _ => "\n\n", From 5a58afb75f5943a0e624e9bcf8f8e15676b16775 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 16:30:43 +0000 Subject: [PATCH 11/23] Preserve instruction source discovery behavior --- codex-rs/core/src/agents_md.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 4389c93fbe8..583d3c169c9 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,6 +23,7 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; @@ -93,12 +94,23 @@ impl<'a> AgentsMdManager<'a> { .await } - /// Returns the AGENTS.md files that contribute model-visible instructions. + /// Returns all instruction source files included in the current config. pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut startup_warnings = Vec::new(); - self.user_instructions_with_fs(fs, &mut startup_warnings) - .await - .map_or_else(Vec::new, |loaded| loaded.sources().cloned().collect()) + let mut global_instruction_warnings = Vec::new(); + let mut paths = Self::load_global_instructions( + LOCAL_FS.as_ref(), + Some(&self.config.codex_home), + &mut global_instruction_warnings, + ) + .await + .map_or_else(Vec::new, |loaded| loaded.sources().cloned().collect()); + match self.agents_md_paths(fs).await { + Ok(agents_md_paths) => paths.extend(agents_md_paths), + Err(err) => { + tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); + } + } + paths } async fn user_instructions_with_fs( From bb8f8d7e416378f125f008fb43cd180b6bb44a45 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 17:35:53 +0000 Subject: [PATCH 12/23] codex: address PR review feedback (#26205) --- codex-rs/core/src/agents_md.rs | 1 + codex-rs/core/src/config/mod.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 583d3c169c9..b1c11d6cada 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -96,6 +96,7 @@ impl<'a> AgentsMdManager<'a> { /// Returns all instruction source files included in the current config. pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { + // TODO(anp) get RPC interface using cached paths let mut global_instruction_warnings = Vec::new(); let mut paths = Self::load_global_instructions( LOCAL_FS.as_ref(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 70d2eea71f6..87433f4c759 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -644,7 +644,7 @@ pub struct Config { /// Defaults to `false`. pub show_raw_agent_reasoning: bool, - /// User-provided instructions, including those loaded from AGENTS.md. + /// User-provided instructions from AGENTS.md. pub user_instructions: Option, /// Base instructions override. From 10ee4a845516abee6f69a4303abf43b510bf5db3 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 18:40:28 +0000 Subject: [PATCH 13/23] codex: address PR review feedback (#26205) --- .../request_processors/thread_processor.rs | 3 +- codex-rs/core/src/agents_md.rs | 43 ++++++++++--------- codex-rs/core/src/agents_md_tests.rs | 8 ++-- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 265832f9b77..060efe4735b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -659,9 +659,10 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } + #[allow(deprecated)] async fn instruction_sources_from_config(config: &Config) -> Vec { codex_core::AgentsMdManager::new(config) - .instruction_sources(LOCAL_FS.as_ref()) + .load_instruction_sources(LOCAL_FS.as_ref()) .await } diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index b1c11d6cada..27e1dd0fa31 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -94,26 +94,6 @@ impl<'a> AgentsMdManager<'a> { .await } - /// Returns all instruction source files included in the current config. - pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - // TODO(anp) get RPC interface using cached paths - let mut global_instruction_warnings = Vec::new(); - let mut paths = Self::load_global_instructions( - LOCAL_FS.as_ref(), - Some(&self.config.codex_home), - &mut global_instruction_warnings, - ) - .await - .map_or_else(Vec::new, |loaded| loaded.sources().cloned().collect()); - match self.agents_md_paths(fs).await { - Ok(agents_md_paths) => paths.extend(agents_md_paths), - Err(err) => { - tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); - } - } - paths - } - async fn user_instructions_with_fs( &self, fs: &dyn ExecutorFileSystem, @@ -141,6 +121,29 @@ impl<'a> AgentsMdManager<'a> { (!loaded.is_empty()).then_some(loaded) } + /// Returns all instruction source files included in the current config. + #[deprecated(note = "TODO(anp) get RPC interface using cached paths")] + pub async fn load_instruction_sources( + &self, + fs: &dyn ExecutorFileSystem, + ) -> Vec { + let mut global_instruction_warnings = Vec::new(); + let mut paths = Self::load_global_instructions( + LOCAL_FS.as_ref(), + Some(&self.config.codex_home), + &mut global_instruction_warnings, + ) + .await + .map_or_else(Vec::new, |loaded| loaded.sources().cloned().collect()); + match self.agents_md_paths(fs).await { + Ok(agents_md_paths) => paths.extend(agents_md_paths), + Err(err) => { + tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); + } + } + paths + } + /// Attempt to locate and load AGENTS.md documentation. /// /// On success returns `Ok(Some(loaded))` where `loaded` contains every diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 45e42a92ccf..610f032fb88 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -519,10 +519,12 @@ async fn instruction_sources_include_global_before_agents_md_docs() { loaded.sources().collect::>(), vec![&global_agents, &project_agents] ); + #[allow(deprecated)] + let loaded_instruction_sources = AgentsMdManager::new(&cfg) + .load_instruction_sources(LOCAL_FS.as_ref()) + .await; assert_eq!( - AgentsMdManager::new(&cfg) - .instruction_sources(LOCAL_FS.as_ref()) - .await, + loaded_instruction_sources, vec![global_agents, project_agents] ); assert_eq!( From daa2e6ad47d9be86c81e7fb466742a98028ef061 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 19:18:13 +0000 Subject: [PATCH 14/23] Use cached AGENTS.md instruction sources --- .../request_processors/thread_processor.rs | 21 +++------ .../app-server/tests/suite/v2/thread_start.rs | 43 +++++++++++++++++++ codex-rs/core/src/agents_md.rs | 24 ----------- codex-rs/core/src/agents_md_tests.rs | 8 ---- codex-rs/core/src/codex_thread.rs | 5 +++ codex-rs/core/src/session/mod.rs | 11 +++++ 6 files changed, 65 insertions(+), 47 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 060efe4735b..6b4df61aa1b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -659,13 +659,6 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } - #[allow(deprecated)] - async fn instruction_sources_from_config(config: &Config) -> Vec { - codex_core::AgentsMdManager::new(config) - .load_instruction_sources(LOCAL_FS.as_ref()) - .await - } - async fn load_thread( &self, thread_id: &str, @@ -1044,7 +1037,6 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; } - let instruction_sources = Self::instruction_sources_from_config(&config).await; let environments = environments.unwrap_or_else(|| { listener_task_context .thread_manager @@ -1114,6 +1106,7 @@ impl ThreadRequestProcessor { ) .await?; + let instruction_sources = thread.instruction_sources().await; let config_snapshot = thread .config_snapshot() .instrument(tracing::info_span!( @@ -2525,13 +2518,12 @@ impl ThreadRequestProcessor { } }; - let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); match self .thread_manager .resume_thread_with_history( - config.clone(), + config, thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, @@ -2554,6 +2546,7 @@ impl ThreadRequestProcessor { self.outgoing.send_error(request_id, err).await; return Ok(()); } + let instruction_sources = codex_thread.instruction_sources().await; let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2854,10 +2847,7 @@ impl ThreadRequestProcessor { /*include_turns*/ false, ); thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); - let mut config_for_instruction_sources = self.config.as_ref().clone(); - config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); - let instruction_sources = - Self::instruction_sources_from_config(&config_for_instruction_sources).await; + let instruction_sources = existing_thread.instruction_sources().await; let listener_command_tx = { let thread_state = thread_state.lock().await; @@ -3234,7 +3224,6 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; let fallback_model_provider = config.model_provider_id.clone(); - let instruction_sources = Self::instruction_sources_from_config(&config).await; let NewThread { thread_id, @@ -3285,6 +3274,8 @@ impl ThreadRequestProcessor { .map_err(|err| core_thread_write_error("inherit source thread name", err))?; } + let instruction_sources = forked_thread.instruction_sources().await; + // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( self.ensure_conversation_listener( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 7a7ed5522a6..51d4a9ffd9d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -359,6 +359,49 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( Ok(()) } +#[tokio::test] +async fn thread_start_response_excludes_empty_project_instruction_source() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + let global_agents_path = codex_home.path().join("AGENTS.md"); + std::fs::write(&global_agents_path, "global instructions")?; + let workspace = TempDir::new()?; + let project_agents_path = workspace.path().join("AGENTS.md"); + std::fs::write(project_agents_path, "")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + let instruction_sources = instruction_sources + .into_iter() + .map(normalize_path_for_comparison) + .collect::>(); + let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize( + global_agents_path, + )?)]; + + assert_eq!(instruction_sources, expected_instruction_sources); + + Ok(()) +} + #[cfg(windows)] fn normalize_path_for_comparison(path: impl AsRef) -> PathBuf { let path = path.as_ref(); diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 27e1dd0fa31..cab433d5102 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,7 +23,6 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; -use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; @@ -121,29 +120,6 @@ impl<'a> AgentsMdManager<'a> { (!loaded.is_empty()).then_some(loaded) } - /// Returns all instruction source files included in the current config. - #[deprecated(note = "TODO(anp) get RPC interface using cached paths")] - pub async fn load_instruction_sources( - &self, - fs: &dyn ExecutorFileSystem, - ) -> Vec { - let mut global_instruction_warnings = Vec::new(); - let mut paths = Self::load_global_instructions( - LOCAL_FS.as_ref(), - Some(&self.config.codex_home), - &mut global_instruction_warnings, - ) - .await - .map_or_else(Vec::new, |loaded| loaded.sources().cloned().collect()); - match self.agents_md_paths(fs).await { - Ok(agents_md_paths) => paths.extend(agents_md_paths), - Err(err) => { - tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); - } - } - paths - } - /// Attempt to locate and load AGENTS.md documentation. /// /// On success returns `Ok(Some(loaded))` where `loaded` contains every diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 610f032fb88..a3e02e5b861 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -519,14 +519,6 @@ async fn instruction_sources_include_global_before_agents_md_docs() { loaded.sources().collect::>(), vec![&global_agents, &project_agents] ); - #[allow(deprecated)] - let loaded_instruction_sources = AgentsMdManager::new(&cfg) - .load_instruction_sources(LOCAL_FS.as_ref()) - .await; - assert_eq!( - loaded_instruction_sources, - vec![global_agents, project_agents] - ); assert_eq!( loaded.text(), format!("global doc{AGENTS_MD_SEPARATOR}project doc") diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ac28a58ec3f..efb60a68b01 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -548,6 +548,11 @@ impl CodexThread { self.codex.thread_config_snapshot().await } + /// Returns the files that supplied the thread's loaded model instructions. + pub async fn instruction_sources(&self) -> Vec { + self.codex.instruction_sources().await + } + pub async fn config(&self) -> Arc { self.codex.session.get_config().await } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 2955cb70842..b8c8c864de9 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -801,6 +801,17 @@ impl Codex { state.session_configuration.thread_config_snapshot() } + pub(crate) async fn instruction_sources(&self) -> Vec { + let state = self.session.state.lock().await; + state + .session_configuration + .user_instructions + .as_ref() + .map_or_else(Vec::new, |instructions| { + instructions.sources().cloned().collect() + }) + } + pub(crate) async fn thread_environment_selections(&self) -> Vec { let state = self.session.state.lock().await; state.session_configuration.environments.clone() From 26f4cf6136f365efb1edba0e791b6a0f37ca5ee4 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 21:51:19 +0000 Subject: [PATCH 15/23] codex: address PR review feedback (#26205) --- codex-rs/core/src/agents_md.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index cab433d5102..0edee1a972d 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -103,7 +103,7 @@ impl<'a> AgentsMdManager<'a> { let mut loaded = self.config.user_instructions.clone().unwrap_or_default(); match agents_md_docs { - Ok(Some(docs)) => loaded.append_project_docs(docs), + Ok(Some(docs)) => loaded.instructions.extend(docs.instructions), Ok(None) => {} Err(e) => { error!("error trying to find AGENTS.md docs: {e:#}"); @@ -348,10 +348,6 @@ impl LoadedAgentsMd { } } - fn append_project_docs(&mut self, project_docs: Self) { - self.instructions.extend(project_docs.instructions); - } - fn is_empty(&self) -> bool { self.instructions .iter() From 3303a0033755180b2bdfb711332af78229a8d6a7 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 21:53:26 +0000 Subject: [PATCH 16/23] codex: address PR review feedback (#26205) --- codex-rs/core/src/agents_md_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index a3e02e5b861..e6b73a3ce7f 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -316,7 +316,7 @@ async fn merges_existing_instructions_with_agents_md() { } #[tokio::test] -async fn source_less_user_instructions_preserve_separator_without_reporting_a_source() { +async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); From 952b8b88832d5b6ed77ed4b550a8ae0c190d1636 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 22:55:08 +0000 Subject: [PATCH 17/23] codex: address PR review feedback (#26205) --- codex-rs/core/src/agents_md.rs | 5 +- codex-rs/exec/tests/suite/agents_md.rs | 75 ++++++++++++++++++++++++++ codex-rs/exec/tests/suite/mod.rs | 1 + 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 codex-rs/exec/tests/suite/agents_md.rs diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 0edee1a972d..d894aa6c6f8 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -26,7 +26,6 @@ use codex_exec_server::ExecutorFileSystem; use codex_features::Feature; use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; -use dunce::canonicalize as normalize_path; use std::io; use toml::Value as TomlValue; use tracing::error; @@ -208,8 +207,8 @@ impl<'a> AgentsMdManager<'a> { } let mut dir = self.config.cwd.clone(); - if let Ok(canon) = normalize_path(&dir) { - dir = AbsolutePathBuf::try_from(canon)?; + if let Ok(canon) = fs.canonicalize(&dir, /*sandbox*/ None).await { + dir = canon; } let mut merged = TomlValue::Table(toml::map::Map::new()); diff --git a/codex-rs/exec/tests/suite/agents_md.rs b/codex-rs/exec/tests/suite/agents_md.rs new file mode 100644 index 00000000000..9d95d77fbab --- /dev/null +++ b/codex-rs/exec/tests/suite/agents_md.rs @@ -0,0 +1,75 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_includes_workspace_agents_md_in_request() -> anyhow::Result<()> { + let test = test_codex_exec(); + std::fs::write(test.cwd_path().join("AGENTS.md"), "workspace instructions")?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success(); + + let user_messages = response_mock.single_request().message_input_texts("user"); + assert!( + user_messages + .iter() + .any(|text| text.contains("workspace instructions")), + "request should include workspace AGENTS.md instructions: {user_messages:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_prefers_workspace_agents_override_md() -> anyhow::Result<()> { + let test = test_codex_exec(); + std::fs::write(test.cwd_path().join("AGENTS.md"), "base instructions")?; + std::fs::write( + test.cwd_path().join("AGENTS.override.md"), + "override instructions", + )?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success(); + + let user_messages = response_mock.single_request().message_input_texts("user"); + assert!( + user_messages + .iter() + .any(|text| text.contains("override instructions")), + "request should include AGENTS.override.md instructions: {user_messages:?}" + ); + assert!( + user_messages + .iter() + .all(|text| !text.contains("base instructions")), + "request should exclude shadowed AGENTS.md instructions: {user_messages:?}" + ); + + Ok(()) +} diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 6f868563273..e7452cc17a6 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,5 +1,6 @@ // Aggregates all former standalone integration tests as modules. mod add_dir; +mod agents_md; mod apply_patch; mod approval_policy; mod auth_env; From 8a65626b0ea5e75a2f77a3188f061fce9c66a1cd Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 23:01:38 +0000 Subject: [PATCH 18/23] codex: address PR review feedback (#26205) --- codex-rs/exec/tests/suite/agents_md.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/exec/tests/suite/agents_md.rs b/codex-rs/exec/tests/suite/agents_md.rs index 9d95d77fbab..2891721ae64 100644 --- a/codex-rs/exec/tests/suite/agents_md.rs +++ b/codex-rs/exec/tests/suite/agents_md.rs @@ -1,4 +1,3 @@ -#![cfg(not(target_os = "windows"))] #![allow(clippy::expect_used, clippy::unwrap_used)] use core_test_support::responses; From 4a20577ad9345e1d97acd74ca1b46e01a3af5fc3 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 23:12:44 +0000 Subject: [PATCH 19/23] codex: address PR review feedback (#26205) --- .../tests/suite/v2/thread_resume.rs | 76 +++++++++++++++++++ .../app-server/tests/suite/v2/thread_start.rs | 34 +++++++++ codex-rs/core/tests/suite/agents_md.rs | 54 +++++++++++++ 3 files changed, 164 insertions(+) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index d18a405164a..4e052041ad3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -256,6 +256,82 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let workspace = TempDir::new()?; + let project_agents = workspace.path().join("AGENTS.md"); + std::fs::write(&project_agents, "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { + thread, + instruction_sources, + .. + } = to_response::(start_resp)?; + let project_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(project_agents)?)?; + assert_eq!(instruction_sources, vec![project_agents.clone()]); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "materialize rollout".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + std::fs::remove_file(project_agents.as_path())?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + instruction_sources, + .. + } = to_response::(resume_resp)?; + + assert_eq!(instruction_sources, vec![project_agents]); + + Ok(()) +} + #[tokio::test] async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 51d4a9ffd9d..208af14299c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -402,6 +402,40 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re Ok(()) } +#[tokio::test] +async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?; + let workspace = TempDir::new()?; + std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + environments: Some(Vec::new()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + assert!(instruction_sources.is_empty()); + + Ok(()) +} + #[cfg(windows)] fn normalize_path_for_comparison(path: impl AsRef) -> PathBuf { let path = path.as_ref(); diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 276fa4bb516..cedc5133050 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,5 +1,6 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -7,6 +8,8 @@ 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 std::sync::Arc; +use tempfile::TempDir; async fn agents_instructions(mut builder: TestCodexBuilder) -> Result { let server = start_mock_server().await; @@ -139,3 +142,54 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { 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 + .fs() + .canonicalize( + &test.executor_environment().cwd().join("AGENTS.md"), + /*sandbox*/ None, + ) + .await?; + let global_agents = + AbsolutePathBuf::try_from(std::fs::canonicalize(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(()) +} From 169c298bd5ef97de225e1317fac1c6ac7f10c16e Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 3 Jun 2026 23:35:32 +0000 Subject: [PATCH 20/23] codex: fix CI failure on PR #26205 --- codex-rs/core/tests/suite/agents_md.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index cedc5133050..318c288dc6f 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -174,8 +174,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu /*sandbox*/ None, ) .await?; - let global_agents = - AbsolutePathBuf::try_from(std::fs::canonicalize(global_agents)?).expect("absolute path"); + let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); assert_eq!( test.codex.instruction_sources().await, From 7ab00dbfce186fd46cdb677034b02d8412128f7f Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Thu, 4 Jun 2026 17:33:02 +0000 Subject: [PATCH 21/23] codex: address PR review feedback (#26205) --- codex-rs/core/src/agents_md.rs | 71 ++++++++++++++-------------- codex-rs/core/src/agents_md_tests.rs | 52 ++++++++++---------- 2 files changed, 62 insertions(+), 61 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index d894aa6c6f8..c55c1b3a65a 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -102,7 +102,7 @@ impl<'a> AgentsMdManager<'a> { let mut loaded = self.config.user_instructions.clone().unwrap_or_default(); match agents_md_docs { - Ok(Some(docs)) => loaded.instructions.extend(docs.instructions), + Ok(Some(docs)) => loaded.entries.extend(docs.entries), Ok(None) => {} Err(e) => { error!("error trying to find AGENTS.md docs: {e:#}"); @@ -110,9 +110,9 @@ impl<'a> AgentsMdManager<'a> { }; if self.config.features.enabled(Feature::ChildAgentsMd) { - loaded.instructions.push(LoadedInstruction { + loaded.entries.push(InstructionEntry { contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - source: InstructionSource::Internal, + provenance: InstructionProvenance::Internal, }); } @@ -178,9 +178,9 @@ impl<'a> AgentsMdManager<'a> { let text = String::from_utf8_lossy(&data).to_string(); if !text.trim().is_empty() { - loaded.instructions.push(LoadedInstruction { + loaded.entries.push(InstructionEntry { contents: text, - source: InstructionSource::Project(p), + provenance: InstructionProvenance::Project(p), }); remaining = remaining.saturating_sub(data.len() as u64); } @@ -207,6 +207,8 @@ impl<'a> AgentsMdManager<'a> { } let mut dir = self.config.cwd.clone(); + // Preserve the existing symlink-aware discovery behavior, but resolve + // the path through the selected environment rather than the host. if let Ok(canon) = fs.canonicalize(&dir, /*sandbox*/ None).await { dir = canon; } @@ -313,7 +315,7 @@ impl<'a> AgentsMdManager<'a> { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LoadedAgentsMd { /// Ordered instructions and their provenance. - instructions: Vec, + entries: Vec, } impl LoadedAgentsMd { @@ -323,9 +325,9 @@ impl LoadedAgentsMd { return Self::default(); } Self { - instructions: vec![LoadedInstruction { + entries: vec![InstructionEntry { contents, - source: InstructionSource::User(path), + provenance: InstructionProvenance::User(path), }], } } @@ -340,53 +342,63 @@ impl LoadedAgentsMd { return Self::default(); } Self { - instructions: vec![LoadedInstruction { + entries: vec![InstructionEntry { contents, - source: InstructionSource::Internal, + provenance: InstructionProvenance::Internal, }], } } fn is_empty(&self) -> bool { - self.instructions + self.entries .iter() - .all(|instruction| instruction.contents.trim().is_empty()) + .all(|entry| entry.contents.trim().is_empty()) } /// Returns the concatenated model-visible instruction text. pub fn text(&self) -> String { let mut output = String::new(); - let mut previous_source = None; - for instruction in &self.instructions { - if let Some(previous_source) = previous_source { - output.push_str(instruction.source.separator_after(previous_source)); + let mut previous_provenance: Option<&InstructionProvenance> = None; + for entry in &self.entries { + if let Some(previous_provenance) = previous_provenance { + // The project-doc marker tells the model where workspace-scoped + // instructions begin, so it is only needed on the transition + // from user or internal instructions to project instructions. + let separator = match (previous_provenance, &entry.provenance) { + ( + InstructionProvenance::User(_) | InstructionProvenance::Internal, + InstructionProvenance::Project(_), + ) => AGENTS_MD_SEPARATOR, + _ => "\n\n", + }; + output.push_str(separator); } - output.push_str(&instruction.contents); - previous_source = Some(&instruction.source); + output.push_str(&entry.contents); + previous_provenance = Some(&entry.provenance); } output } /// Returns the AGENTS.md files that supplied instruction entries. pub fn sources(&self) -> impl Iterator { - self.instructions + self.entries .iter() - .filter_map(|instruction| instruction.source.path()) + .filter_map(|entry| entry.provenance.path()) } } /// One model-visible instruction and its provenance. #[derive(Clone, Debug, PartialEq, Eq)] -struct LoadedInstruction { +struct InstructionEntry { /// Model-visible instruction text. contents: String, /// Origin of the instruction. - source: InstructionSource, + provenance: InstructionProvenance, } #[derive(Clone, Debug, PartialEq, Eq)] -enum InstructionSource { +enum InstructionProvenance { /// User-level instructions, normally loaded from CODEX_HOME. User(AbsolutePathBuf), @@ -397,18 +409,7 @@ enum InstructionSource { Internal, } -impl InstructionSource { - fn separator_after(&self, previous: &Self) -> &'static str { - // The project-doc marker tells the model where workspace-scoped - // instructions begin. It belongs only before the first project entry; - // subsequent project docs and trailing internal guidance are part of - // an already established instruction section. - match (previous, self) { - (Self::User(_) | Self::Internal, Self::Project(_)) => AGENTS_MD_SEPARATOR, - _ => "\n\n", - } - } - +impl InstructionProvenance { fn path(&self) -> Option<&AbsolutePathBuf> { match self { Self::User(path) | Self::Project(path) => Some(path), diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index e6b73a3ce7f..95659511b01 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -152,15 +152,15 @@ fn empty_loaded_instructions_are_empty() { #[test] fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() { let empty = LoadedAgentsMd { - instructions: vec![LoadedInstruction { + entries: vec![InstructionEntry { contents: String::new(), - source: InstructionSource::Internal, + provenance: InstructionProvenance::Internal, }], }; let whitespace = LoadedAgentsMd { - instructions: vec![LoadedInstruction { + entries: vec![InstructionEntry { contents: " \n\t".to_string(), - source: InstructionSource::Internal, + provenance: InstructionProvenance::Internal, }], }; @@ -394,14 +394,14 @@ async fn concatenates_root_and_cwd_docs() { ) .expect("absolute crate doc path"); let expected = LoadedAgentsMd { - instructions: vec![ - LoadedInstruction { + entries: vec![ + InstructionEntry { contents: "root doc".to_string(), - source: InstructionSource::Project(root_agents.clone()), + provenance: InstructionProvenance::Project(root_agents.clone()), }, - LoadedInstruction { + InstructionEntry { contents: "crate doc".to_string(), - source: InstructionSource::Project(crate_agents.clone()), + provenance: InstructionProvenance::Project(crate_agents.clone()), }, ], }; @@ -463,14 +463,14 @@ async fn child_agents_message_after_global_instructions_uses_plain_separator() { .expect("instructions expected"); let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); let expected = LoadedAgentsMd { - instructions: vec![ - LoadedInstruction { + entries: vec![ + InstructionEntry { contents: "global doc".to_string(), - source: InstructionSource::User(global_agents), + provenance: InstructionProvenance::User(global_agents), }, - LoadedInstruction { + InstructionEntry { contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - source: InstructionSource::Internal, + provenance: InstructionProvenance::Internal, }, ], }; @@ -503,14 +503,14 @@ async fn instruction_sources_include_global_before_agents_md_docs() { .expect("absolute project doc path"); let expected = LoadedAgentsMd { - instructions: vec![ - LoadedInstruction { + entries: vec![ + InstructionEntry { contents: "global doc".to_string(), - source: InstructionSource::User(global_agents.clone()), + provenance: InstructionProvenance::User(global_agents.clone()), }, - LoadedInstruction { + InstructionEntry { contents: "project doc".to_string(), - source: InstructionSource::Project(project_agents.clone()), + provenance: InstructionProvenance::Project(project_agents.clone()), }, ], }; @@ -547,18 +547,18 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source() .expect("absolute project doc path"); let expected = LoadedAgentsMd { - instructions: vec![ - LoadedInstruction { + entries: vec![ + InstructionEntry { contents: "global doc".to_string(), - source: InstructionSource::User(global_agents.clone()), + provenance: InstructionProvenance::User(global_agents.clone()), }, - LoadedInstruction { + InstructionEntry { contents: "project doc".to_string(), - source: InstructionSource::Project(project_agents.clone()), + provenance: InstructionProvenance::Project(project_agents.clone()), }, - LoadedInstruction { + InstructionEntry { contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - source: InstructionSource::Internal, + provenance: InstructionProvenance::Internal, }, ], }; From 32cf518b8ba95e2faf1db560c52c94a4baf75c87 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Thu, 4 Jun 2026 17:49:59 +0000 Subject: [PATCH 22/23] codex: avoid canonicalizing AGENTS.md cwd --- codex-rs/core/src/agents_md.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index c55c1b3a65a..5739e42f309 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -206,12 +206,7 @@ impl<'a> AgentsMdManager<'a> { return Ok(Vec::new()); } - let mut dir = self.config.cwd.clone(); - // Preserve the existing symlink-aware discovery behavior, but resolve - // the path through the selected environment rather than the host. - if let Ok(canon) = fs.canonicalize(&dir, /*sandbox*/ None).await { - dir = canon; - } + let dir = self.config.cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.config.config_layer_stack.get_layers( From 8363196dc1ed5ba9bfc4c26a2d9869cedf60088f Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Thu, 4 Jun 2026 19:16:25 +0000 Subject: [PATCH 23/23] codex: canonicalize AGENTS.md cwd through environment fs --- codex-rs/core/src/agents_md.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 5739e42f309..bc3740a46fd 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -206,7 +206,10 @@ impl<'a> AgentsMdManager<'a> { return Ok(Vec::new()); } - let dir = self.config.cwd.clone(); + let mut dir = self.config.cwd.clone(); + if let Ok(canonical_dir) = fs.canonicalize(&dir, /*sandbox*/ None).await { + dir = canonical_dir; + } let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.config.config_layer_stack.get_layers(