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
Unverified
parent 470510174b
commit 46ab9974dc
23 changed files with 302 additions and 69 deletions
@@ -105,6 +105,7 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) -
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: Vec::new(),
approval_policy: AppServerAskForApproval::OnFailure,
approvals_reviewer: AppServerApprovalsReviewer::User,
sandbox: AppServerSandboxPolicy::DangerFullAccess,
@@ -155,6 +156,7 @@ fn sample_thread_resume_response_with_source(
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: Vec::new(),
approval_policy: AppServerAskForApproval::OnFailure,
approvals_reviewer: AppServerApprovalsReviewer::User,
sandbox: AppServerSandboxPolicy::DangerFullAccess,
@@ -12806,6 +12806,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -14095,6 +14103,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -14386,6 +14402,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -10654,6 +10654,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -11943,6 +11951,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -12234,6 +12250,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -1405,6 +1405,7 @@ mod tests {
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: vec![PathBuf::from("/tmp/AGENTS.md")],
approval_policy: v2::AskForApproval::OnFailure,
approvals_reviewer: v2::ApprovalsReviewer::User,
sandbox: v2::SandboxPolicy::DangerFullAccess,
@@ -1444,6 +1445,7 @@ mod tests {
"modelProvider": "openai",
"serviceTier": null,
"cwd": "/tmp",
"instructionSources": ["/tmp/AGENTS.md"],
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": {
@@ -2719,6 +2719,9 @@ pub struct ThreadStartResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -2805,6 +2808,9 @@ pub struct ThreadResumeResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -2882,6 +2888,9 @@ pub struct ThreadForkResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -8506,6 +8515,50 @@ mod tests {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn thread_lifecycle_responses_default_missing_instruction_sources() {
let response = json!({
"thread": {
"id": "thread-id",
"forkedFromId": null,
"preview": "",
"ephemeral": false,
"modelProvider": "openai",
"createdAt": 1,
"updatedAt": 1,
"status": { "type": "idle" },
"path": null,
"cwd": "/tmp",
"cliVersion": "0.0.0",
"source": "exec",
"agentNickname": null,
"agentRole": null,
"gitInfo": null,
"name": null,
"turns": []
},
"model": "gpt-5",
"modelProvider": "openai",
"serviceTier": null,
"cwd": "/tmp",
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": { "type": "dangerFullAccess" },
"reasoningEffort": null
});
let start: ThreadStartResponse =
serde_json::from_value(response.clone()).expect("thread/start response");
let resume: ThreadResumeResponse =
serde_json::from_value(response.clone()).expect("thread/resume response");
let fork: ThreadForkResponse =
serde_json::from_value(response).expect("thread/fork response");
assert_eq!(start.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(resume.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<PathBuf>::new());
}
#[test]
fn turn_start_params_preserve_explicit_null_service_tier() {
let params: TurnStartParams = serde_json::from_value(json!({
@@ -239,6 +239,7 @@ use codex_core::sandboxing::SandboxPermissions;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
use codex_exec_server::LOCAL_FS;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::Stage;
@@ -491,6 +492,23 @@ pub(crate) struct CodexMessageProcessorArgs {
}
impl CodexMessageProcessor {
async fn instruction_sources_from_config(config: &Config) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = config.user_instructions_path.iter().cloned().collect();
match codex_core::discover_project_doc_paths(config, LOCAL_FS.as_ref()).await {
Ok(project_doc_paths) => {
paths.extend(
project_doc_paths
.into_iter()
.map(|path| path.as_path().to_path_buf()),
);
}
Err(err) => {
tracing::warn!(error = %err, "failed to discover project docs for thread response");
}
}
paths
}
pub(crate) fn handle_config_mutation(&self) {
self.clear_plugin_related_caches();
}
@@ -2312,6 +2330,7 @@ impl CodexMessageProcessor {
};
}
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let dynamic_tools = dynamic_tools.unwrap_or_default();
let core_dynamic_tools = if dynamic_tools.is_empty() {
Vec::new()
@@ -2443,6 +2462,7 @@ impl CodexMessageProcessor {
model_provider: config_snapshot.model_provider_id,
service_tier: config_snapshot.service_tier,
cwd: config_snapshot.cwd,
instruction_sources,
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox: config_snapshot.sandbox_policy.into(),
@@ -3881,6 +3901,7 @@ impl CodexMessageProcessor {
};
let fallback_model_provider = config.model_provider_id.clone();
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let response_history = thread_history.clone();
match self
@@ -3961,6 +3982,7 @@ impl CodexMessageProcessor {
model_provider: session_configured.model_provider_id,
service_tier: session_configured.service_tier,
cwd: session_configured.cwd,
instruction_sources,
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox: session_configured.sandbox_policy.into(),
@@ -4119,6 +4141,12 @@ impl CodexMessageProcessor {
mismatch_details.join("; ")
);
}
let mut config_for_instruction_sources = self.config.as_ref().clone();
if let Ok(cwd) = AbsolutePathBuf::try_from(config_snapshot.cwd.clone()) {
config_for_instruction_sources.cwd = cwd;
}
let instruction_sources =
Self::instruction_sources_from_config(&config_for_instruction_sources).await;
let thread_summary = match load_thread_summary_for_rollout(
&self.config,
existing_thread_id,
@@ -4156,6 +4184,7 @@ impl CodexMessageProcessor {
request_id: request_id.clone(),
rollout_path: rollout_path.clone(),
config_snapshot,
instruction_sources,
thread_summary,
}),
);
@@ -4431,6 +4460,7 @@ impl CodexMessageProcessor {
};
let fallback_model_provider = config.model_provider_id.clone();
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let NewThread {
thread_id,
@@ -4585,6 +4615,7 @@ impl CodexMessageProcessor {
model_provider: session_configured.model_provider_id,
service_tier: session_configured.service_tier,
cwd: session_configured.cwd,
instruction_sources,
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox: session_configured.sandbox_policy.into(),
@@ -8156,12 +8187,14 @@ async fn handle_pending_thread_resume_request(
reasoning_effort,
..
} = pending.config_snapshot;
let instruction_sources = pending.instruction_sources;
let response = ThreadResumeResponse {
thread,
model,
model_provider: model_provider_id,
service_tier,
cwd,
instruction_sources,
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
sandbox: sandbox_policy.into(),
+1
View File
@@ -27,6 +27,7 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) rollout_path: PathBuf,
pub(crate) config_snapshot: ThreadConfigSnapshot,
pub(crate) instruction_sources: Vec<PathBuf>,
pub(crate) thread_summary: codex_app_server_protocol::Thread,
}
@@ -29,6 +29,7 @@ use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -162,6 +163,64 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_start_response_includes_loaded_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())?;
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, "project instructions")?;
let mut mcp = McpProcess::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![
std::fs::canonicalize(global_agents_path)?,
std::fs::canonicalize(project_agents_path)?,
]
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>();
assert_eq!(instruction_sources, expected_instruction_sources);
Ok(())
}
#[cfg(windows)]
fn normalize_path_for_comparison(path: PathBuf) -> PathBuf {
let path = path.display().to_string();
PathBuf::from(path.strip_prefix(r"\\?\").unwrap_or(&path))
}
#[cfg(not(windows))]
fn normalize_path_for_comparison(path: PathBuf) -> PathBuf {
path
}
#[tokio::test]
async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
+1
View File
@@ -410,6 +410,7 @@ fn session_configured_from_thread_response_uses_review_policy_from_response() {
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: Vec::new(),
approval_policy: codex_app_server_protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent,
sandbox: codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
+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;