Expose instruction sources (AGENTS.md) via app server (#17506)

Addresses #17498

Problem: The TUI derived /status instruction source paths from the local
client environment, which could show stale <none> output or incorrect
paths when connected to a remote app server.

Solution: Add an app-server v2 instructionSources snapshot to thread
start/resume/fork responses, default it to an empty list when older
servers omit it, and render TUI /status from that server-provided
session data.

Additional context: The app-server field is intentionally named
instructionSources rather than AGENTS.md-specific terminology because
the loaded instruction sources can include global instructions, project
AGENTS.md files, AGENTS.override.md, user-defined instruction files, and
future dynamic sources.
This commit is contained in:
Eric Traut
2026-04-12 15:50:12 -07:00
committed by GitHub
parent 470510174b
commit 46ab9974dc
23 changed files with 302 additions and 69 deletions
+3
View File
@@ -3062,6 +3062,7 @@ impl App {
approvals_reviewer: self.config.approvals_reviewer,
sandbox_policy: self.config.permissions.sandbox_policy.get().clone(),
cwd: thread.cwd.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: self.chat_widget.current_reasoning_effort(),
history_log_id: 0,
history_entry_count: 0,
@@ -3072,6 +3073,7 @@ impl App {
session.thread_name = thread.name.clone();
session.model_provider_id = thread.model_provider.clone();
session.cwd = thread.cwd.clone();
session.instruction_source_paths = Vec::new();
session.rollout_path = thread.path.clone();
if let Some(model) =
read_session_model(&self.config, thread_id, thread.path.as_deref()).await
@@ -9365,6 +9367,7 @@ guardian_approval = true
approvals_reviewer: ApprovalsReviewer::User,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd,
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
+13
View File
@@ -132,6 +132,7 @@ pub(crate) struct ThreadSessionState {
pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) cwd: PathBuf,
pub(crate) instruction_source_paths: Vec<PathBuf>,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: u64,
@@ -993,6 +994,7 @@ async fn thread_session_state_from_thread_start_response(
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.instruction_sources.clone(),
response.reasoning_effort,
config,
)
@@ -1015,6 +1017,7 @@ async fn thread_session_state_from_thread_resume_response(
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.instruction_sources.clone(),
response.reasoning_effort,
config,
)
@@ -1037,6 +1040,7 @@ async fn thread_session_state_from_thread_fork_response(
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.instruction_sources.clone(),
response.reasoning_effort,
config,
)
@@ -1078,6 +1082,7 @@ async fn thread_session_state_from_thread_response(
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
sandbox_policy: SandboxPolicy,
cwd: PathBuf,
instruction_source_paths: Vec<PathBuf>,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
config: &Config,
) -> Result<ThreadSessionState, String> {
@@ -1102,6 +1107,7 @@ async fn thread_session_state_from_thread_response(
approvals_reviewer,
sandbox_policy,
cwd,
instruction_source_paths,
reasoning_effort,
history_log_id,
history_entry_count,
@@ -1326,6 +1332,7 @@ mod tests {
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp/project"),
instruction_sources: vec![PathBuf::from("/tmp/project/AGENTS.md")],
approval_policy: codex_protocol::protocol::AskForApproval::Never.into(),
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
sandbox: codex_protocol::protocol::SandboxPolicy::new_read_only_policy().into(),
@@ -1336,6 +1343,10 @@ mod tests {
.await
.expect("resume response should map");
assert_eq!(started.session.forked_from_id, Some(forked_from_id));
assert_eq!(
started.session.instruction_source_paths,
response.instruction_sources
);
assert_eq!(started.turns.len(), 1);
assert_eq!(started.turns[0], response.thread.turns[0]);
}
@@ -1365,6 +1376,7 @@ mod tests {
codex_protocol::config_types::ApprovalsReviewer::User,
SandboxPolicy::new_read_only_policy(),
PathBuf::from("/tmp/project"),
Vec::new(),
/*reasoning_effort*/ None,
&config,
)
@@ -1394,6 +1406,7 @@ mod tests {
codex_protocol::config_types::ApprovalsReviewer::User,
SandboxPolicy::new_read_only_policy(),
PathBuf::from("/tmp/project"),
Vec::new(),
/*reasoning_effort*/ None,
&config,
)
+7 -15
View File
@@ -942,6 +942,8 @@ pub(crate) struct ChatWidget {
current_rollout_path: Option<PathBuf>,
// Current working directory (if known)
current_cwd: Option<PathBuf>,
// Instruction source files loaded for the current session, supplied by app-server.
instruction_source_paths: Vec<PathBuf>,
// Runtime network proxy bind addresses from SessionConfigured.
session_network_proxy: Option<codex_protocol::protocol::SessionNetworkProxyRuntime>,
// Shared latch so we only warn once about invalid status-line item IDs.
@@ -2071,6 +2073,7 @@ impl ChatWidget {
}
pub(crate) fn handle_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
self.on_session_configured(thread_session_state_to_legacy_event(session));
}
@@ -4865,6 +4868,7 @@ impl ChatWidget {
feedback,
current_rollout_path: None,
current_cwd,
instruction_source_paths: Vec::new(),
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
@@ -7071,8 +7075,8 @@ impl ChatWidget {
.values()
.cloned()
.collect();
let config = self.config.clone();
let frame_requester = self.frame_requester.clone();
let agents_summary =
crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths);
let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle(
&self.config,
self.status_account_display.as_ref(),
@@ -7087,21 +7091,9 @@ impl ChatWidget {
self.model_display_name(),
collaboration_mode,
reasoning_effort_override,
"<none>".to_string(),
agents_summary,
refreshing_rate_limits,
);
let agents_summary_handle = handle.clone();
tokio::spawn(async move {
let agents_summary = match crate::status::discover_agents_summary(&config).await {
Ok(summary) => summary,
Err(err) => {
tracing::warn!(error = %err, "failed to discover project docs for /status");
"<none>".to_string()
}
};
agents_summary_handle.finish_agents_summary_discovery(agents_summary);
frame_requester.schedule_frame();
});
if let Some(request_id) = request_id {
self.refreshing_status_outputs.push((request_id, handle));
}
@@ -275,6 +275,7 @@ pub(super) async fn make_chatwidget_manual(
feedback: codex_feedback::CodexFeedback::new(),
current_rollout_path: None,
current_cwd: None,
instruction_source_paths: Vec::new(),
session_network_proxy: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
@@ -76,6 +76,29 @@ async fn status_command_renders_immediately_without_rate_limit_refresh() {
);
}
#[tokio::test]
async fn status_command_renders_instruction_sources_from_thread_session() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.instruction_source_paths = vec![chat.config.cwd.join("AGENTS.md").to_path_buf()];
chat.dispatch_command(SlashCommand::Status);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 80))
}
other => panic!("expected status output, got {other:?}"),
};
assert!(
rendered.contains("Agents.md"),
"expected /status to render app-server instruction sources, got: {rendered}"
);
assert!(
!rendered.contains("Agents.md <none>"),
"expected /status to avoid stale <none> when app-server provided instruction sources, got: {rendered}"
);
}
#[tokio::test]
async fn status_command_overlapping_refreshes_update_matching_cells_only() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+2 -15
View File
@@ -67,20 +67,10 @@ struct StatusRateLimitState {
#[derive(Debug, Clone)]
pub(crate) struct StatusHistoryHandle {
agents_summary: Arc<RwLock<String>>,
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
}
impl StatusHistoryHandle {
pub(crate) fn finish_agents_summary_discovery(&self, agents_summary: String) {
#[expect(clippy::expect_used)]
let mut current = self
.agents_summary
.write()
.expect("status history agents summary state poisoned");
*current = agents_summary;
}
pub(crate) fn finish_rate_limit_refresh(
&self,
rate_limits: &[RateLimitSnapshotDisplay],
@@ -360,13 +350,10 @@ impl StatusHistoryCell {
session_id,
forked_from,
token_usage,
agents_summary: agents_summary.clone(),
agents_summary,
rate_limit_state: rate_limit_state.clone(),
},
StatusHistoryHandle {
agents_summary,
rate_limit_state,
},
StatusHistoryHandle { rate_limit_state },
)
}
+14 -35
View File
@@ -1,15 +1,12 @@
use crate::exec_command::relativize_to_home;
use crate::legacy_core::config::Config;
use crate::legacy_core::discover_project_doc_paths;
use crate::status::StatusAccountDisplay;
use crate::text_formatting;
use chrono::DateTime;
use chrono::Local;
use codex_exec_server::LOCAL_FS;
use codex_protocol::account::PlanType;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use unicode_width::UnicodeWidthStr;
fn normalize_agents_display_path(path: &Path) -> String {
@@ -36,16 +33,8 @@ pub(crate) fn compose_model_display(
(model_name.to_string(), details)
}
pub(crate) async fn discover_agents_summary(config: &Config) -> io::Result<String> {
let paths = discover_project_doc_paths(config, LOCAL_FS.as_ref()).await?;
Ok(compose_agents_summary(config, &paths))
}
pub(crate) fn compose_agents_summary(config: &Config, paths: &[AbsolutePathBuf]) -> String {
pub(crate) fn compose_agents_summary(config: &Config, paths: &[PathBuf]) -> String {
let mut rels: Vec<String> = Vec::new();
if let Some(path) = config.user_instructions_path.as_deref() {
rels.push(format_directory_display(path, /*max_width*/ None));
}
for p in paths {
let file_name = p
@@ -53,14 +42,14 @@ pub(crate) fn compose_agents_summary(config: &Config, paths: &[AbsolutePathBuf])
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "<unknown>".to_string());
let display = if let Some(parent) = p.parent() {
if parent.as_path() == config.cwd.as_path() {
if parent == config.cwd.as_path() {
file_name.clone()
} else {
let mut cur = config.cwd.as_path();
let mut ups = 0usize;
let mut reached = false;
while let Some(c) = cur.parent() {
if cur == parent.as_path() {
if cur == parent {
reached = true;
break;
}
@@ -199,7 +188,6 @@ mod tests {
use crate::legacy_core::LOCAL_PROJECT_DOC_FILENAME;
use crate::legacy_core::config::ConfigBuilder;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::TempDir;
async fn test_config(codex_home: &TempDir, cwd: &TempDir) -> Config {
@@ -234,52 +222,43 @@ mod tests {
}
#[tokio::test]
async fn discover_agents_summary_includes_global_agents_path() {
async fn compose_agents_summary_includes_global_agents_path() {
let codex_home = TempDir::new().expect("temp codex home");
let cwd = TempDir::new().expect("temp cwd");
let global_agents_path = codex_home.path().join(DEFAULT_PROJECT_DOC_FILENAME);
fs::write(&global_agents_path, "global instructions").expect("write global agents");
let config = test_config(&codex_home, &cwd).await;
assert_eq!(
discover_agents_summary(&config).await.expect("summary"),
compose_agents_summary(&config, std::slice::from_ref(&global_agents_path)),
format_directory_display(&global_agents_path, /*max_width*/ None)
);
}
#[tokio::test]
async fn discover_agents_summary_names_global_agents_override() {
async fn compose_agents_summary_names_global_agents_override() {
let codex_home = TempDir::new().expect("temp codex home");
let cwd = TempDir::new().expect("temp cwd");
fs::write(
codex_home.path().join(DEFAULT_PROJECT_DOC_FILENAME),
"global instructions",
)
.expect("write global agents");
let override_path = codex_home.path().join(LOCAL_PROJECT_DOC_FILENAME);
fs::write(&override_path, "override instructions").expect("write global override");
let config = test_config(&codex_home, &cwd).await;
assert_eq!(
discover_agents_summary(&config).await.expect("summary"),
compose_agents_summary(&config, std::slice::from_ref(&override_path)),
format_directory_display(&override_path, /*max_width*/ None)
);
}
#[tokio::test]
async fn discover_agents_summary_orders_global_before_project_agents() {
async fn compose_agents_summary_orders_global_before_project_agents() {
let codex_home = TempDir::new().expect("temp codex home");
let cwd = TempDir::new().expect("temp cwd");
let global_agents_path = codex_home.path().join(DEFAULT_PROJECT_DOC_FILENAME);
fs::write(&global_agents_path, "global instructions").expect("write global agents");
fs::write(
cwd.path().join(DEFAULT_PROJECT_DOC_FILENAME),
"project instructions",
)
.expect("write project agents");
let project_agents_path = cwd.path().join(DEFAULT_PROJECT_DOC_FILENAME);
let config = test_config(&codex_home, &cwd).await;
let summary = discover_agents_summary(&config).await.expect("summary");
let summary = compose_agents_summary(
&config,
&[global_agents_path.clone(), project_agents_path.clone()],
);
let mut paths = summary.split(", ");
assert_eq!(
paths.next(),
+1 -1
View File
@@ -19,7 +19,7 @@ pub(crate) use card::new_status_output;
#[cfg(test)]
pub(crate) use card::new_status_output_with_rate_limits;
pub(crate) use card::new_status_output_with_rate_limits_handle;
pub(crate) use helpers::discover_agents_summary;
pub(crate) use helpers::compose_agents_summary;
pub(crate) use helpers::format_directory_display;
pub(crate) use helpers::format_tokens_compact;
pub(crate) use helpers::plan_type_display_name;