Route AGENTS.md loading through environment filesystems (#26205)

## Why

Workspace-specific `AGENTS.md` loading needs to use the selected
environment filesystem so remote workspaces and child agents read
instructions from their actual environment instead of the host
filesystem. The app-server should report the same instruction sources
the initialized thread actually loaded, rather than independently
rescanning configuration and filesystem state.

## What changed

- Introduce `LoadedAgentsMd` to retain ordered user, project, and
internal instructions with their provenance.
- Load and canonicalize workspace `AGENTS.md` paths through the primary
`EnvironmentManager` environment, then render the loaded instructions
when constructing turn context.
- Expose cached loaded instruction sources from initialized threads and
use them for app-server start, resume, and fork responses.
- Preserve global `CODEX_HOME` loading and separator behavior while
excluding empty project files that did not supply model-visible
instructions.
- Add integration coverage for CLI injection, selected-environment
provenance and rendering, empty environment selection, and cached
sources on loaded-thread resume.

## Validation

- `just test -p codex-core agents_md`
- `just test -p codex-core
selected_environment_sources_match_model_visible_instructions`
- `just test -p codex-exec agents_md`
- `just test -p codex-app-server instruction_sources`
- `just test -p codex-app-server --status-level fail`
This commit is contained in:
Adam Perry @ OpenAI
2026-06-04 12:43:07 -07:00
committed by GitHub
Unverified
parent c3fcb0e745
commit e64b469bbc
22 changed files with 740 additions and 137 deletions
@@ -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::<ThreadStartResponse>(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::<ThreadResumeResponse>(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;
@@ -359,6 +359,83 @@ 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::<ThreadStartResponse>(response)?;
let instruction_sources = instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>();
let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize(
global_agents_path,
)?)];
assert_eq!(instruction_sources, expected_instruction_sources);
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::<ThreadStartResponse>(response)?;
assert!(instruction_sources.is_empty());
Ok(())
}
#[cfg(windows)]
fn normalize_path_for_comparison(path: impl AsRef<Path>) -> PathBuf {
let path = path.as_ref();