From 46ab9974dc14ae0bc626ba8d48cc3839ecd69995 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 12 Apr 2026 15:50:12 -0700 Subject: [PATCH] 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 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. --- .../analytics/src/analytics_client_tests.rs | 2 + .../codex_app_server_protocol.schemas.json | 24 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++++++ .../schema/json/v2/ThreadForkResponse.json | 8 +++ .../schema/json/v2/ThreadResumeResponse.json | 8 +++ .../schema/json/v2/ThreadStartResponse.json | 8 +++ .../typescript/v2/ThreadForkResponse.ts | 6 +- .../typescript/v2/ThreadResumeResponse.ts | 6 +- .../typescript/v2/ThreadStartResponse.ts | 6 +- .../src/protocol/common.rs | 2 + .../app-server-protocol/src/protocol/v2.rs | 53 +++++++++++++++++ .../app-server/src/codex_message_processor.rs | 33 +++++++++++ codex-rs/app-server/src/thread_state.rs | 1 + .../app-server/tests/suite/v2/thread_start.rs | 59 +++++++++++++++++++ codex-rs/exec/src/lib_tests.rs | 1 + codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app_server_session.rs | 13 ++++ codex-rs/tui/src/chatwidget.rs | 22 +++---- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../chatwidget/tests/status_command_tests.rs | 23 ++++++++ codex-rs/tui/src/status/card.rs | 17 +----- codex-rs/tui/src/status/helpers.rs | 49 +++++---------- codex-rs/tui/src/status/mod.rs | 2 +- 23 files changed, 302 insertions(+), 69 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560..e5aca39b8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -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, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index dd839cd58..15b31cc0e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 66b91082e..dd053d77d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 9b1870198..5af02d344 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 5143545ec..d888485d1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index c07b4d825..7a0e08309 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index 5e9d460a9..df6c50227 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -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, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 9c7ec4527..3234e8b4b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -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, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index c85f81f52..e3355b910 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -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, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 3f9ba7bfd..3450e4153 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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": { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4ce7e9c0b..30adc152e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2719,6 +2719,9 @@ pub struct ThreadStartResponse { pub model_provider: String, pub service_tier: Option, pub cwd: PathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, #[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, pub cwd: PathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, #[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, pub cwd: PathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, #[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::::new()); + assert_eq!(resume.instruction_sources, Vec::::new()); + assert_eq!(fork.instruction_sources, Vec::::new()); + } + #[test] fn turn_start_params_preserve_explicit_null_service_tier() { let params: TurnStartParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 53f4a876c..6762b9e12 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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 { + let mut paths: Vec = 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(), diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 805a64bfb..11d6ad6bb 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -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, pub(crate) thread_summary: codex_app_server_protocol::Thread, } 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 50c373ac9..c0df2cab0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -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::(response)?; + + let instruction_sources = instruction_sources + .into_iter() + .map(normalize_path_for_comparison) + .collect::>(); + 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::>(); + + 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; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 0af5cc525..519a1d6cb 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -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 { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7dea884de..a8db00dc2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 779efa02b..5f75bd390 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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, pub(crate) reasoning_effort: Option, 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, reasoning_effort: Option, config: &Config, ) -> Result { @@ -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, ) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7f47edbb9..e7ef6a060 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -942,6 +942,8 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, // Current working directory (if known) current_cwd: Option, + // Instruction source files loaded for the current session, supplied by app-server. + instruction_source_paths: Vec, // Runtime network proxy bind addresses from SessionConfigured. session_network_proxy: Option, // 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, - "".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"); - "".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)); } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d5c6a5e3b..96fdb0a40 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -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)), diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index ba14ceb03..dcb8d82fc 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -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 "), + "expected /status to avoid stale 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; diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index dccb86cdc..340f93368 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -67,20 +67,10 @@ struct StatusRateLimitState { #[derive(Debug, Clone)] pub(crate) struct StatusHistoryHandle { - agents_summary: Arc>, rate_limit_state: Arc>, } 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 }, ) } diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index ef28ed7fc..0ee90d411 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -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 { - 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 = 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(|| "".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(), diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index 1781104ef..d61886338 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -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;