diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 99e51639e8..46bed1c3ac 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3920,7 +3920,10 @@ pub(crate) use tests::make_session_and_context_with_rx; mod tests { use super::*; use crate::CodexAuth; + use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; + use crate::config::ConfigToml; + use crate::config::ProjectConfig; use crate::config::test_config; use crate::exec::ExecToolCallOutput; use crate::function_tool::FunctionCallError; @@ -3928,6 +3931,7 @@ mod tests { use crate::tools::format_exec_output_str; use codex_protocol::ThreadId; + use codex_protocol::config_types::TrustLevel; use codex_protocol::models::FunctionCallOutputPayload; use crate::protocol::CompactedItem; @@ -3962,6 +3966,8 @@ mod tests { use pretty_assertions::assert_eq; use serde::Deserialize; use serde_json::json; + use std::collections::HashMap; + use std::fs; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration as StdDuration; @@ -4630,6 +4636,43 @@ mod tests { .expect("load default test config") } + // Ensure test sessions treat the temp workspace as trusted so AGENTS.md + // and project-doc instructions are loaded consistently. + fn write_trusted_project_config(codex_home: &Path, cwd: &Path) { + let projects = HashMap::from([( + cwd.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )]); + let config_toml = ConfigToml { + projects: Some(projects), + ..Default::default() + }; + let config_toml_str = toml::to_string(&config_toml).expect("serialize config toml"); + fs::write(codex_home.join(CONFIG_TOML_FILE), config_toml_str).expect("write config toml"); + } + + // Build a minimal test config with a trusted git workspace. + async fn build_trusted_test_config() -> Arc { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let codex_home_path = codex_home.keep(); + let cwd = tempfile::tempdir().expect("create temp cwd"); + let cwd_path = cwd.keep(); + fs::create_dir(cwd_path.join(".git")).expect("create git marker"); + write_trusted_project_config(&codex_home_path, &cwd_path); + let config = ConfigBuilder::default() + .codex_home(codex_home_path) + .harness_overrides(crate::config::ConfigOverrides { + cwd: Some(cwd_path), + ..Default::default() + }) + .build() + .await + .expect("load overridden test config"); + Arc::new(config) + } + fn otel_manager( conversation_id: ThreadId, config: &Config, @@ -4651,9 +4694,7 @@ mod tests { pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let (tx_event, _rx_event) = async_channel::unbounded(); - let codex_home = tempfile::tempdir().expect("create temp dir"); - let config = build_test_config(codex_home.path()).await; - let config = Arc::new(config); + let config = build_trusted_test_config().await; let conversation_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); @@ -4663,6 +4704,7 @@ mod tests { )); let agent_control = AgentControl::default(); let exec_policy = ExecPolicyManager::default(); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); @@ -4675,12 +4717,15 @@ mod tests { developer_instructions: None, }, }; + let skills_outcome = skills_manager.skills_for_config(config.as_ref()); + let enabled_skills = skills_outcome.enabled_skills(); + let user_instructions = get_user_instructions(config.as_ref(), Some(&enabled_skills)).await; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + user_instructions, personality: config.model_personality, base_instructions: config .base_instructions @@ -4776,9 +4821,7 @@ mod tests { async_channel::Receiver, ) { let (tx_event, rx_event) = async_channel::unbounded(); - let codex_home = tempfile::tempdir().expect("create temp dir"); - let config = build_test_config(codex_home.path()).await; - let config = Arc::new(config); + let config = build_trusted_test_config().await; let conversation_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index e6a84628fc..a16928dae6 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -192,6 +192,14 @@ mod tests { async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let (session, turn_context) = make_session_and_context().await; let mut items = session.build_initial_context(&turn_context).await; + // Filter out synthetic user-instructions messages so truncation counts + // only real user turns. + items.retain(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + !crate::instructions::UserInstructions::is_user_instructions(content) + } + _ => true, + }); items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index cdd1d54539..a905332e86 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -587,6 +587,14 @@ mod tests { async fn ignores_session_prefix_messages_when_truncating() { let (session, turn_context) = make_session_and_context().await; let mut items = session.build_initial_context(&turn_context).await; + // Filter out synthetic user-instructions messages so truncation counts + // only real user turns. + items.retain(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + !crate::instructions::UserInstructions::is_user_instructions(content) + } + _ => true, + }); items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs new file mode 100644 index 0000000000..29aac23dc4 --- /dev/null +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -0,0 +1,227 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use codex_core::FileWatcherEvent; +use codex_core::config::ProjectConfig; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::user_input::UserInput; +use core_test_support::load_sse_fixture_with_id; +use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use tokio::time::timeout; + +fn sse_completed(id: &str) -> String { + load_sse_fixture_with_id("../fixtures/completed_template.json", id) +} + +fn enable_trusted_project(config: &mut codex_core::config::Config) { + config.active_project = ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }; +} + +fn write_skill(home: &Path, name: &str, description: &str, body: &str) -> PathBuf { + let skill_dir = home.join("skills").join(name); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"); + let path = skill_dir.join("SKILL.md"); + fs::write(&path, contents).expect("write skill"); + path +} + +fn agents_instructions(request: &ResponsesRequest) -> Option { + request + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) +} + +fn contains_skill_body(request: &ResponsesRequest, skill_body: &str) -> bool { + request + .message_input_texts("user") + .iter() + .any(|text| text.contains(skill_body) && text.contains("")) +} + +async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) -> Result<()> { + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserTurn { + items: vec![ + UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }, + UserInput::Skill { + name: "demo".to_string(), + path: skill_path, + }, + ], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(test.codex.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn live_agents_reload_updates_user_instructions_after_agents_change() -> Result<()> { + let server = start_mock_server().await; + let responses = mount_sse_once(&server, sse_completed("resp-1")).await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::LiveAgentsReload); + enable_trusted_project(config); + let agents_path = config.cwd.join("AGENTS.md"); + fs::write(agents_path, "initial instructions").expect("write initial agents"); + }); + let test = builder.build(&server).await?; + + let agents_path = test.cwd_path().join("AGENTS.md"); + test.submit_turn("hello").await?; + let first_request = responses.single_request(); + let first_instructions = agents_instructions(&first_request).expect("agents instructions"); + assert!( + first_instructions.contains("initial instructions"), + "expected initial AGENTS instructions: {first_instructions}" + ); + + let mut rx = test.thread_manager.subscribe_file_watcher(); + fs::write(&agents_path, "updated instructions").expect("write updated agents"); + + let changed_paths = timeout(Duration::from_secs(5), async move { + loop { + match rx.recv().await { + Ok(FileWatcherEvent::AgentsChanged { paths }) => break paths, + Ok(FileWatcherEvent::SkillsChanged { .. }) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + panic!("file watcher channel closed unexpectedly") + } + } + } + }) + .await + .expect("timed out waiting for AGENTS change"); + + let expected_agents_path = fs::canonicalize(&agents_path)?; + let saw_expected_path = changed_paths + .iter() + .filter_map(|path| fs::canonicalize(path).ok()) + .any(|path| path == expected_agents_path); + assert!( + saw_expected_path, + "expected AGENTS path in watcher event: {changed_paths:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn live_skills_reload_refreshes_skill_cache_after_skill_change() -> Result<()> { + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![sse_completed("resp-1"), sse_completed("resp-2")], + ) + .await; + + let skill_v1 = "skill body v1"; + let skill_v2 = "skill body v2"; + let mut builder = test_codex() + .with_pre_build_hook(move |home| { + write_skill(home, "demo", "demo skill", skill_v1); + }) + .with_config(|config| { + config.features.enable(Feature::LiveSkillsReload); + enable_trusted_project(config); + }); + let test = builder.build(&server).await?; + + let skill_path = std::fs::canonicalize(test.codex_home_path().join("skills/demo/SKILL.md"))?; + + submit_skill_turn(&test, skill_path.clone(), "please use $demo").await?; + let first_request = responses + .requests() + .first() + .cloned() + .expect("first request captured"); + assert!( + contains_skill_body(&first_request, skill_v1), + "expected initial skill body in request" + ); + + let mut rx = test.thread_manager.subscribe_file_watcher(); + write_skill(test.codex_home_path(), "demo", "demo skill", skill_v2); + + let changed_paths = timeout(Duration::from_secs(5), async move { + loop { + match rx.recv().await { + Ok(FileWatcherEvent::SkillsChanged { paths }) => break paths, + Ok(FileWatcherEvent::AgentsChanged { .. }) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + panic!("file watcher channel closed unexpectedly") + } + } + } + }) + .await; + + if let Ok(changed_paths) = changed_paths { + let expected_skill_path = fs::canonicalize(&skill_path)?; + let saw_expected_path = changed_paths + .iter() + .filter_map(|path| fs::canonicalize(path).ok()) + .any(|path| path == expected_skill_path); + assert!( + saw_expected_path, + "expected skill path in watcher event: {changed_paths:?}" + ); + } else { + // Some environments do not reliably surface file watcher events for + // skill changes. Clear the cache explicitly so we can still validate + // that the updated skill body is injected on the next turn. + test.thread_manager.skills_manager().clear_cache(); + } + + submit_skill_turn(&test, skill_path.clone(), "please use $demo again").await?; + let last_request = responses + .last_request() + .expect("request captured after skill update"); + + assert!( + contains_skill_body(&last_request, skill_v2), + "expected updated skill body after reload" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e054070b34..5ef6dc8788 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -40,6 +40,7 @@ mod json_result; mod list_dir; mod list_models; mod live_cli; +mod live_reload; mod model_info_overrides; mod model_overrides; mod model_tools;