From f5aceef813187034818cd820bc6dc88eda61a2e0 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 14:52:48 -0700 Subject: [PATCH 01/14] test: characterize global instruction lifecycle --- .../app-server/tests/suite/v2/thread_fork.rs | 86 ++++ .../app-server/tests/suite/v2/thread_start.rs | 3 +- .../core/tests/suite/compact_remote_parity.rs | 27 +- .../suite/global_instructions_regression.rs | 410 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + .../core/tests/suite/prompt_debug_tests.rs | 11 +- codex-rs/mcp-server/tests/suite/codex_tool.rs | 19 + 7 files changed, 547 insertions(+), 10 deletions(-) create mode 100644 codex-rs/core/tests/suite/global_instructions_regression.rs diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index a335b441ffcc..f190764a4c5b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -36,6 +36,7 @@ use codex_protocol::protocol::RolloutItem; use codex_rollout::append_rollout_item_to_path; use codex_rollout::append_thread_name; use codex_rollout::read_session_meta_line; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -249,6 +250,91 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_currently_reloads_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let global_agents = codex_home.path().join("AGENTS.md"); + std::fs::write(&global_agents, "global instructions")?; + let workspace = TempDir::new()?; + let project_agents = workspace.path().join("AGENTS.md"); + std::fs::write(&project_agents, "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { + thread, + instruction_sources, + .. + } = to_response::(start_resp)?; + let expected_sources = vec![ + AbsolutePathBuf::try_from(std::fs::canonicalize(&global_agents)?)?, + AbsolutePathBuf::try_from(project_agents.clone())?, + ]; + assert_eq!(instruction_sources, expected_sources); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "persist instruction snapshot".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + std::fs::remove_file(global_agents)?; + std::fs::remove_file(project_agents)?; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { + instruction_sources, + .. + } = to_response::(fork_resp)?; + + assert_eq!( + instruction_sources, + Vec::::new(), + "fork currently reloads sources after the files have been removed" + ); + + Ok(()) +} + #[tokio::test] async fn thread_fork_inherits_explicit_source_name_from_session_index() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; 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 6366c6fbcaa5..20430a714734 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -400,7 +400,8 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re } #[tokio::test] -async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> { +async fn thread_start_without_selected_environment_currently_excludes_instruction_sources() +-> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 1b7c50936cb6..4e5137e4bf84 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -29,6 +29,7 @@ const FIXED_CWD: &str = "/tmp/codex_remote_compaction_parity_workspace"; const IMAGE_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; const SUMMARY: &str = "REMOTE_COMPACTION_PARITY_ENCRYPTED_SUMMARY"; const DUMMY_FUNCTION_NAME: &str = "test_tool"; +const USER_INSTRUCTIONS: &str = "PARITY_USER_INSTRUCTIONS"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Mode { @@ -239,6 +240,17 @@ fn assert_capture_eq(label: &str, legacy: &Capture, v2: &Capture) { &legacy_follow_up, &v2_follow_up, ); + assert!( + legacy + .follow_up_body + .to_string() + .contains(USER_INSTRUCTIONS), + "legacy follow-up should retain the configured user instructions for {label}" + ); + assert!( + v2.follow_up_body.to_string().contains(USER_INSTRUCTIONS), + "v2 follow-up should retain the configured user instructions for {label}" + ); assert_json_eq( &format!("replacement history parity mismatch for {label}"), @@ -284,6 +296,17 @@ fn assert_follow_up_and_history_eq(label: &str, legacy: &Capture, v2: &Capture) &legacy_follow_up, &v2_follow_up, ); + assert!( + legacy + .follow_up_body + .to_string() + .contains(USER_INSTRUCTIONS), + "legacy follow-up should retain the configured user instructions for {label}" + ); + assert!( + v2.follow_up_body.to_string().contains(USER_INSTRUCTIONS), + "v2 follow-up should retain the configured user instructions for {label}" + ); assert_json_eq( &format!("replacement history parity mismatch for {label}"), @@ -518,9 +541,7 @@ async fn build_harness_inner( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "PARITY_USER_INSTRUCTIONS", - )); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(USER_INSTRUCTIONS)); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); diff --git a/codex-rs/core/tests/suite/global_instructions_regression.rs b/codex-rs/core/tests/suite/global_instructions_regression.rs new file mode 100644 index 000000000000..02d5e4388362 --- /dev/null +++ b/codex-rs/core/tests/suite/global_instructions_regression.rs @@ -0,0 +1,410 @@ +use std::sync::Arc; + +use anyhow::Result; +use codex_core::ForkSnapshot; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::built_in_model_providers; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::responses; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; + +fn write_global(home: &TempDir, contents: impl AsRef<[u8]>) -> Result { + let path = home.path().join(GLOBAL_AGENTS_FILENAME); + std::fs::write(&path, contents)?; + AbsolutePathBuf::try_from(path).map_err(Into::into) +} + +fn user_instructions(request: &responses::ResponsesRequest) -> String { + request + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("global instructions message") +} + +fn local_compaction_provider(server: &wiremock::MockServer) -> ModelProviderInfo { + let mut provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + provider.name = "OpenAI-compatible test provider".to_string(); + provider.base_url = Some(format!("{}/v1", server.uri())); + provider.supports_websockets = false; + provider +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("response-1"), + responses::ev_completed("response-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("response-2"), + responses::ev_completed("response-2"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let global_source = write_global(home.as_ref(), "global instructions")?; + + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &cwd.join("AGENTS.md"), + b"project instructions".to_vec(), + /*sandbox*/ None, + ) + .await?; + Ok(()) + }); + let test = builder.build_with_remote_env(&server).await?; + + assert_eq!( + test.codex.instruction_sources().await, + vec![global_source, test.config.cwd.join("AGENTS.md")] + ); + + test.submit_turn("first turn").await?; + test.submit_turn("second turn").await?; + + let requests = response_mock.requests(); + let rendered = user_instructions(&requests[0]); + assert!( + rendered.find("global instructions") < rendered.find("project instructions"), + "global instructions should precede project instructions: {rendered}" + ); + assert!( + rendered.contains("--- project-doc ---"), + "global/project boundary should retain the project separator: {rendered}" + ); + assert_eq!( + &requests[1].input()[..requests[0].input().len()], + requests[0].input(), + "the ordinary second turn should retain the cached prefix" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> { + let server = responses::start_mock_server().await; + let home = Arc::new(TempDir::new()?); + let source = write_global(home.as_ref(), b"global\xFFinstructions")?; + + let mut builder = test_codex().with_home(home); + let test = builder.build(&server).await?; + + let warning = wait_for_event_match(&test.codex, |event| match event { + EventMsg::Warning(warning) + if warning + .message + .contains(source.as_path().display().to_string().as_str()) => + { + Some(warning.message.clone()) + } + _ => None, + }) + .await; + assert!(warning.contains("invalid UTF-8")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cold_resume_replays_rendered_instructions_but_reports_current_config_sources() -> Result<()> +{ + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("initial-response"), + responses::ev_completed("initial-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("resumed-response"), + responses::ev_completed("resumed-response"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let old_source = write_global(home.as_ref(), "old global instructions")?; + + let mut initial_builder = test_codex().with_home(Arc::clone(&home)); + let initial = initial_builder.build(&server).await?; + initial.submit_turn("persist instructions").await?; + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + std::fs::remove_file(old_source.as_path())?; + let new_source = write_global(home.as_ref(), "new global instructions")?; + let mut resume_builder = test_codex().with_home(Arc::clone(&home)); + let resumed = resume_builder + .resume(&server, Arc::clone(&home), rollout_path) + .await?; + + assert_eq!( + resumed.codex.instruction_sources().await, + vec![new_source], + "resume currently reports sources from the newly loaded config" + ); + + resumed.submit_turn("continue resumed thread").await?; + + let resumed_request = response_mock.requests()[1].body_json().to_string(); + assert!(resumed_request.contains("old global instructions")); + assert!(!resumed_request.contains("new global instructions")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("parent-response"), + responses::ev_completed("parent-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("fork-response"), + responses::ev_completed("fork-response"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global(home.as_ref(), "old global instructions")?; + let mut builder = test_codex().with_home(Arc::clone(&home)); + let parent = builder.build(&server).await?; + parent.submit_turn("persist instructions").await?; + parent.codex.ensure_rollout_materialized().await; + parent.codex.flush_rollout().await?; + let rollout_path = parent.codex.rollout_path().expect("rollout path"); + + std::fs::write(source.as_path(), "new global instructions")?; + let forked = parent + .thread_manager + .fork_thread( + ForkSnapshot::Interrupted, + parent.config.clone(), + rollout_path, + /*thread_source*/ None, + /*parent_trace*/ None, + ) + .await?; + + forked + .thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "continue fork".to_string(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&forked.thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let rendered = user_instructions(&response_mock.requests()[1]); + assert!(rendered.contains("old global instructions")); + assert!(!rendered.contains("new global instructions")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("first-response"), + responses::ev_completed("first-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("compact-response"), + responses::ev_assistant_message("compact-message", "summary"), + responses::ev_completed("compact-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("follow-up-response"), + responses::ev_completed("follow-up-response"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global(home.as_ref(), "old global instructions")?; + let provider = local_compaction_provider(&server); + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(move |config| { + config.model_provider = provider; + }); + let test = builder.build(&server).await?; + + test.submit_turn("first turn").await?; + std::fs::write(source.as_path(), "new global instructions")?; + + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("after compact").await?; + + let follow_up = user_instructions(&response_mock.requests()[2]); + assert!(follow_up.contains("old global instructions")); + assert!(!follow_up.contains("new global instructions")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_function_call("call-1", "unsupported_tool", "{}"), + responses::ev_completed_with_tokens("first-response", /*total_tokens*/ 96), + ]), + responses::sse(vec![ + responses::ev_assistant_message("compact-message", "summary"), + responses::ev_completed_with_tokens("compact-response", /*total_tokens*/ 10), + ]), + responses::sse(vec![ + responses::ev_assistant_message("final-message", "done"), + responses::ev_completed_with_tokens("follow-up-response", /*total_tokens*/ 10), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global(home.as_ref(), "old global instructions")?; + let provider = local_compaction_provider(&server); + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(move |config| { + config.model_provider = provider; + config.model_context_window = Some(100); + config.model_auto_compact_token_limit = Some(90); + }); + let test = builder.build(&server).await?; + + std::fs::write(source.as_path(), "new global instructions")?; + test.submit_turn("trigger mid-turn compaction").await?; + + let continuation = user_instructions(&response_mock.requests()[2]); + assert!(continuation.contains("old global instructions")); + assert!(!continuation.contains("new global instructions")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("initial-response"), + responses::ev_completed("initial-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("resumed-response"), + responses::ev_completed("resumed-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("compact-response"), + responses::ev_assistant_message("compact-message", "summary"), + responses::ev_completed("compact-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("post-compact-response"), + responses::ev_completed("post-compact-response"), + ]), + ], + ) + .await; + let provider = local_compaction_provider(&server); + let home = Arc::new(TempDir::new()?); + let source = write_global(home.as_ref(), "old global instructions")?; + let mut initial_builder = test_codex().with_home(Arc::clone(&home)).with_config({ + let provider = provider.clone(); + move |config| config.model_provider = provider + }); + let initial = initial_builder.build(&server).await?; + initial.submit_turn("persist legacy history").await?; + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + std::fs::write(source.as_path(), "new global instructions")?; + let mut resume_builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(move |config| config.model_provider = provider); + let resumed = resume_builder + .resume(&server, Arc::clone(&home), rollout_path) + .await?; + resumed.submit_turn("resume legacy history").await?; + let resumed_rendered = response_mock.requests()[1].body_json().to_string(); + assert!(resumed_rendered.contains("old global instructions")); + assert!(!resumed_rendered.contains("new global instructions")); + + resumed.codex.submit(Op::Compact).await?; + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + resumed.submit_turn("rebuild full context").await?; + + let rebuilt = user_instructions(&response_mock.requests()[3]); + assert!(rebuilt.contains("new global instructions")); + assert!(!rebuilt.contains("old global instructions")); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 234fb8a2f0bf..780ee6b7909e 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -52,6 +52,7 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; +mod global_instructions_regression; #[cfg(not(target_os = "windows"))] mod guardian_review; mod hierarchical_agents; diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index bad7e1ab5aa2..b397464bf144 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use codex_core::LoadedAgentsMd; use codex_core::build_prompt_input; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -13,7 +12,11 @@ use tempfile::TempDir; async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - let mut config = ConfigBuilder::default() + std::fs::write( + codex_home.path().join("AGENTS.md"), + "Project-specific test instructions", + )?; + let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), @@ -22,10 +25,6 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "Project-specific test instructions", - )); - let input = build_prompt_input( config, vec![UserInput::Text { diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index d9f290c3c59f..659e2f133909 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -371,6 +371,10 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { // Run `codex mcp` with a specific config.toml. let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; + std::fs::write( + codex_home.path().join("AGENTS.md"), + "MCP global instructions", + )?; let mut mcp_process = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??; @@ -442,6 +446,21 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { developer_contents.contains(&"Foreshadow upcoming tool calls."), "expected developer instructions in developer messages, got {developer_contents:?}" ); + let user_contents = request["input"] + .as_array() + .expect("responses request should include input items") + .iter() + .filter(|msg| msg.get("role").and_then(serde_json::Value::as_str) == Some("user")) + .filter_map(|msg| msg.get("content").and_then(serde_json::Value::as_array)) + .flatten() + .filter_map(|span| span.get("text").and_then(serde_json::Value::as_str)) + .collect::>(); + assert!( + user_contents + .iter() + .any(|content| content.contains("MCP global instructions")), + "expected CODEX_HOME instructions in user messages, got {user_contents:?}" + ); Ok(()) } From 31be4bfd9e18c79a72a46bd60414e1ff6d421855 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 16:40:14 -0700 Subject: [PATCH 02/14] test: strengthen global instruction lifecycle coverage --- .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 79 +++++ .../app-server/tests/suite/v2/thread_start.rs | 3 +- .../core/tests/suite/compact_remote_parity.rs | 77 +++-- .../suite/global_instructions_regression.rs | 293 +++++++++++++++--- .../core/tests/suite/prompt_debug_tests.rs | 33 +- codex-rs/mcp-server/tests/suite/codex_tool.rs | 6 +- 7 files changed, 413 insertions(+), 82 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index f190764a4c5b..1dc07963adeb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -251,7 +251,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { } #[tokio::test] -async fn thread_fork_currently_reloads_instruction_sources() -> Result<()> { +async fn thread_fork_reloads_instruction_sources() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -329,7 +329,7 @@ async fn thread_fork_currently_reloads_instruction_sources() -> Result<()> { assert_eq!( instruction_sources, Vec::::new(), - "fork currently reloads sources after the files have been removed" + "fork reloads sources after the files have been removed" ); Ok(()) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 582177ee7da7..1336ee1b2322 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -332,6 +332,85 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul Ok(()) } +#[tokio::test] +async fn thread_resume_cold_thread_reloads_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let old_global_agents = codex_home.path().join("AGENTS.md"); + std::fs::write(&old_global_agents, "old global instructions")?; + let workspace = TempDir::new()?; + + let mut first_mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; + + let start_id = first_mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { + thread, + instruction_sources, + .. + } = to_response::(start_resp)?; + let old_global_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(old_global_agents)?)?; + assert_eq!(instruction_sources, vec![old_global_agents]); + + let turn_id = first_mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize rollout".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + drop(first_mcp); + + let new_global_agents = codex_home.path().join("AGENTS.override.md"); + std::fs::write(&new_global_agents, "new global instructions")?; + + let mut second_mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; + let resume_id = second_mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + second_mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + instruction_sources, + .. + } = to_response::(resume_resp)?; + let new_global_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(new_global_agents)?)?; + assert_eq!(instruction_sources, vec![new_global_agents]); + + Ok(()) +} + #[tokio::test] async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; 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 20430a714734..6366c6fbcaa5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -400,8 +400,7 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re } #[tokio::test] -async fn thread_start_without_selected_environment_currently_excludes_instruction_sources() --> Result<()> { +async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 4e5137e4bf84..aff6d5a3d25c 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -240,16 +240,13 @@ fn assert_capture_eq(label: &str, legacy: &Capture, v2: &Capture) { &legacy_follow_up, &v2_follow_up, ); - assert!( - legacy - .follow_up_body - .to_string() - .contains(USER_INSTRUCTIONS), - "legacy follow-up should retain the configured user instructions for {label}" + assert_single_user_instruction_message( + &legacy.follow_up_body, + &format!("legacy follow-up for {label}"), ); - assert!( - v2.follow_up_body.to_string().contains(USER_INSTRUCTIONS), - "v2 follow-up should retain the configured user instructions for {label}" + assert_single_user_instruction_message( + &v2.follow_up_body, + &format!("v2 follow-up for {label}"), ); assert_json_eq( @@ -296,16 +293,13 @@ fn assert_follow_up_and_history_eq(label: &str, legacy: &Capture, v2: &Capture) &legacy_follow_up, &v2_follow_up, ); - assert!( - legacy - .follow_up_body - .to_string() - .contains(USER_INSTRUCTIONS), - "legacy follow-up should retain the configured user instructions for {label}" + assert_single_user_instruction_message( + &legacy.follow_up_body, + &format!("legacy follow-up for {label}"), ); - assert!( - v2.follow_up_body.to_string().contains(USER_INSTRUCTIONS), - "v2 follow-up should retain the configured user instructions for {label}" + assert_single_user_instruction_message( + &v2.follow_up_body, + &format!("v2 follow-up for {label}"), ); assert_json_eq( @@ -797,6 +791,53 @@ fn follow_up_request_view(body: &Value) -> Value { canonical_json(&normalize_value(selected)) } +fn assert_single_user_instruction_message(body: &Value, label: &str) { + let instruction_messages = body + .get("input") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user") + }) + .filter_map(|item| { + let content = item.get("content").and_then(Value::as_array)?; + let instruction_content = content + .iter() + .filter(|span| { + span.get("type").and_then(Value::as_str) == Some("input_text") + && span + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| text.starts_with("# AGENTS.md instructions for ")) + }) + .cloned() + .collect::>(); + (!instruction_content.is_empty()).then(|| { + json!({ + "role": item["role"].clone(), + "instruction_content": instruction_content, + }) + }) + }) + .collect::>(); + let expected_text = format!( + "# AGENTS.md instructions for {FIXED_CWD}\n\n\n{USER_INSTRUCTIONS}\n" + ); + assert_eq!( + instruction_messages, + vec![json!({ + "role": "user", + "instruction_content": [{ + "type": "input_text", + "text": expected_text, + }], + })], + "{label} should contain exactly one structured user-instruction message" + ); +} + fn replacement_history_from_rollout(path: &Path) -> Result { let rollout_text = fs::read_to_string(path)?; let mut replacement_history = None; diff --git a/codex-rs/core/tests/suite/global_instructions_regression.rs b/codex-rs/core/tests/suite/global_instructions_regression.rs index 02d5e4388362..8da620d23b96 100644 --- a/codex-rs/core/tests/suite/global_instructions_regression.rs +++ b/codex-rs/core/tests/suite/global_instructions_regression.rs @@ -1,34 +1,90 @@ use std::sync::Arc; +use std::time::Duration; use anyhow::Result; +use anyhow::anyhow; use codex_core::ForkSnapshot; +use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::load_default_config_for_test; use core_test_support::responses; +use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::TempDir; const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; +const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; +const GLOBAL_INSTRUCTIONS: &str = "global instructions"; +const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; +const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; +const PROJECT_INSTRUCTIONS: &str = "project instructions"; +const PROJECT_SEPARATOR: &str = "--- project-doc ---"; +const SPAWN_CALL_ID: &str = "spawn-global-instructions-child"; +const SPAWN_CHILD_PROMPT: &str = "inspect inherited global instructions"; +const SPAWN_PARENT_PROMPT: &str = "spawn a child with the parent context"; +const SPAWN_SEED_PROMPT: &str = "seed parent history"; fn write_global(home: &TempDir, contents: impl AsRef<[u8]>) -> Result { - let path = home.path().join(GLOBAL_AGENTS_FILENAME); + write_global_file(home, GLOBAL_AGENTS_FILENAME, contents) +} + +fn write_global_override(home: &TempDir, contents: impl AsRef<[u8]>) -> Result { + write_global_file(home, GLOBAL_AGENTS_OVERRIDE_FILENAME, contents) +} + +fn write_global_file( + home: &TempDir, + filename: &str, + contents: impl AsRef<[u8]>, +) -> Result { + let path = home.path().join(filename); std::fs::write(&path, contents)?; AbsolutePathBuf::try_from(path).map_err(Into::into) } -fn user_instructions(request: &responses::ResponsesRequest) -> String { +fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { request .message_input_texts("user") .into_iter() - .find(|text| text.starts_with("# AGENTS.md instructions for ")) - .expect("global instructions message") + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .collect() +} + +fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { + let cwd = cwd.as_path().display(); + format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") +} + +fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) { + assert_eq!(instruction_fragments(request), vec![expected.to_string()]); +} + +fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { + let is_zstd = request + .headers + .get("content-encoding") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) + }); + let body = if is_zstd { + zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok() + } else { + Some(request.body.clone()) + }; + body.and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) } fn local_compaction_provider(server: &wiremock::MockServer) -> ModelProviderInfo { @@ -57,14 +113,14 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re ) .await; let home = Arc::new(TempDir::new()?); - let global_source = write_global(home.as_ref(), "global instructions")?; + let global_source = write_global(home.as_ref(), GLOBAL_INSTRUCTIONS)?; let mut builder = test_codex() .with_home(Arc::clone(&home)) .with_workspace_setup(|cwd, fs| async move { fs.write_file( &cwd.join("AGENTS.md"), - b"project instructions".to_vec(), + PROJECT_INSTRUCTIONS.as_bytes().to_vec(), /*sandbox*/ None, ) .await?; @@ -81,14 +137,32 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re test.submit_turn("second turn").await?; let requests = response_mock.requests(); - let rendered = user_instructions(&requests[0]); + let expected_contents = + format!("{GLOBAL_INSTRUCTIONS}\n\n{PROJECT_SEPARATOR}\n\n{PROJECT_INSTRUCTIONS}"); + let expected_fragment = expected_instruction_fragment(&test.config.cwd, &expected_contents); + let fragments = instruction_fragments(&requests[0]); + assert_eq!(fragments, vec![expected_fragment]); + let rendered = fragments + .into_iter() + .next() + .ok_or_else(|| anyhow!("expected one rendered instruction fragment"))?; + let global_position = rendered.find(GLOBAL_INSTRUCTIONS).ok_or_else(|| { + anyhow!( + "expected rendered instructions to contain {GLOBAL_INSTRUCTIONS:?}; observed: {rendered}" + ) + })?; + let project_position = rendered.find(PROJECT_INSTRUCTIONS).ok_or_else(|| { + anyhow!( + "expected rendered instructions to contain {PROJECT_INSTRUCTIONS:?}; observed: {rendered}" + ) + })?; assert!( - rendered.find("global instructions") < rendered.find("project instructions"), + global_position < project_position, "global instructions should precede project instructions: {rendered}" ); assert!( - rendered.contains("--- project-doc ---"), - "global/project boundary should retain the project separator: {rendered}" + rendered.contains(PROJECT_SEPARATOR), + "expected rendered instructions to contain {PROJECT_SEPARATOR:?}; observed: {rendered}" ); assert_eq!( &requests[1].input()[..requests[0].input().len()], @@ -119,7 +193,10 @@ async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> _ => None, }) .await; - assert!(warning.contains("invalid UTF-8")); + assert!( + warning.contains("invalid UTF-8"), + "expected warning to contain \"invalid UTF-8\"; observed: {warning}" + ); Ok(()) } @@ -143,7 +220,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so ) .await; let home = Arc::new(TempDir::new()?); - let old_source = write_global(home.as_ref(), "old global instructions")?; + let old_source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let mut initial_builder = test_codex().with_home(Arc::clone(&home)); let initial = initial_builder.build(&server).await?; @@ -159,8 +236,8 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so }) .await; - std::fs::remove_file(old_source.as_path())?; - let new_source = write_global(home.as_ref(), "new global instructions")?; + let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_ne!(old_source, new_source); let mut resume_builder = test_codex().with_home(Arc::clone(&home)); let resumed = resume_builder .resume(&server, Arc::clone(&home), rollout_path) @@ -169,14 +246,24 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so assert_eq!( resumed.codex.instruction_sources().await, vec![new_source], - "resume currently reports sources from the newly loaded config" + "resume reports sources from the newly loaded config" ); resumed.submit_turn("continue resumed thread").await?; - let resumed_request = response_mock.requests()[1].body_json().to_string(); - assert!(resumed_request.contains("old global instructions")); - assert!(!resumed_request.contains("new global instructions")); + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let initial_input = requests[0].input(); + let resumed_input = requests[1].input(); + assert_eq!( + resumed_input.get(..initial_input.len()), + Some(initial_input.as_slice()), + "cold resume should replay the original structured input prefix" + ); + let expected_fragment = + expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &expected_fragment); + assert_single_instruction_fragment(&requests[1], &expected_fragment); Ok(()) } @@ -199,7 +286,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> ) .await; let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), "old global instructions")?; + let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let mut builder = test_codex().with_home(Arc::clone(&home)); let parent = builder.build(&server).await?; parent.submit_turn("persist instructions").await?; @@ -207,17 +294,29 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> parent.codex.flush_rollout().await?; let rollout_path = parent.codex.rollout_path().expect("rollout path"); - std::fs::write(source.as_path(), "new global instructions")?; + let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_ne!(source, new_source); + let mut fork_config = load_default_config_for_test(home.as_ref()).await; + fork_config.cwd = parent.config.cwd.clone(); + fork_config.model = parent.config.model.clone(); + fork_config.model_provider = parent.config.model_provider.clone(); + fork_config.model_catalog = parent.config.model_catalog.clone(); + fork_config.codex_self_exe = parent.config.codex_self_exe.clone(); let forked = parent .thread_manager .fork_thread( ForkSnapshot::Interrupted, - parent.config.clone(), + fork_config, rollout_path, /*thread_source*/ None, /*parent_trace*/ None, ) .await?; + assert_eq!( + forked.thread.instruction_sources().await, + vec![new_source], + "fork config should reflect the newly loaded global source" + ); forked .thread @@ -238,9 +337,117 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> }) .await; - let rendered = user_instructions(&response_mock.requests()[1]); - assert!(rendered.contains("old global instructions")); - assert!(!rendered.contains("new global instructions")); + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let parent_input = requests[0].input(); + let fork_input = requests[1].input(); + assert_eq!( + fork_input.get(..parent_input.len()), + Some(parent_input.as_slice()), + "fork should replay the parent's original structured input prefix" + ); + let expected_fragment = + expected_instruction_fragment(&parent.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &expected_fragment); + assert_single_instruction_fragment(&requests[1], &expected_fragment); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let seed_mock = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| request_body_contains(request, SPAWN_SEED_PROMPT), + responses::sse(vec![ + responses::ev_response_created("seed-response"), + responses::ev_assistant_message("seed-message", "seeded"), + responses::ev_completed("seed-response"), + ]), + ) + .await; + let spawn_args = serde_json::to_string(&json!({ + "message": SPAWN_CHILD_PROMPT, + "fork_context": true, + }))?; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| request_body_contains(request, SPAWN_PARENT_PROMPT), + responses::sse(vec![ + responses::ev_response_created("spawn-response"), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + "multi_agent_v1", + "spawn_agent", + &spawn_args, + ), + responses::ev_completed("spawn-response"), + ]), + ) + .await; + let child_mock = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + request_body_contains(request, SPAWN_CHILD_PROMPT) + && !request_body_contains(request, SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("child-response"), + responses::ev_assistant_message("child-message", "done"), + responses::ev_completed("child-response"), + ]), + ) + .await; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| request_body_contains(request, SPAWN_CALL_ID), + responses::sse(vec![ + responses::ev_response_created("spawn-follow-up-response"), + responses::ev_assistant_message("spawn-follow-up-message", "child started"), + responses::ev_completed("spawn-follow-up-response"), + ]), + ) + .await; + + let home = Arc::new(TempDir::new()?); + write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(|config| { + let _ = config.features.enable(Feature::Collab); + let _ = config.features.disable(Feature::EnableRequestCompression); + }); + let test = builder.build(&server).await?; + test.submit_turn(SPAWN_SEED_PROMPT).await?; + let seed_request = seed_mock.single_request(); + + write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + test.submit_turn(SPAWN_PARENT_PROMPT).await?; + let child_request = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Some(request) = child_mock.requests().into_iter().next() { + break request; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .map_err(|_| anyhow!("timed out waiting for the forked subagent request"))?; + + let expected_fragment = + expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&seed_request, &expected_fragment); + assert_single_instruction_fragment(&child_request, &expected_fragment); + let seed_input = seed_request.input(); + let child_input = child_request.input(); + assert_eq!( + child_input.get(..seed_input.len()), + Some(seed_input.as_slice()), + "forked subagent should replay the parent's original structured input prefix" + ); Ok(()) } @@ -268,7 +475,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu ) .await; let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), "old global instructions")?; + let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let provider = local_compaction_provider(&server); let mut builder = test_codex() .with_home(Arc::clone(&home)) @@ -278,7 +485,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu let test = builder.build(&server).await?; test.submit_turn("first turn").await?; - std::fs::write(source.as_path(), "new global instructions")?; + std::fs::write(source.as_path(), NEW_GLOBAL_INSTRUCTIONS)?; test.codex.submit(Op::Compact).await?; wait_for_event(&test.codex, |event| { @@ -287,9 +494,10 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu .await; test.submit_turn("after compact").await?; - let follow_up = user_instructions(&response_mock.requests()[2]); - assert!(follow_up.contains("old global instructions")); - assert!(!follow_up.contains("new global instructions")); + let requests = response_mock.requests(); + let expected_fragment = + expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[2], &expected_fragment); Ok(()) } @@ -316,7 +524,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re ) .await; let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), "old global instructions")?; + let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let provider = local_compaction_provider(&server); let mut builder = test_codex() .with_home(Arc::clone(&home)) @@ -327,12 +535,13 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re }); let test = builder.build(&server).await?; - std::fs::write(source.as_path(), "new global instructions")?; + std::fs::write(source.as_path(), NEW_GLOBAL_INSTRUCTIONS)?; test.submit_turn("trigger mid-turn compaction").await?; - let continuation = user_instructions(&response_mock.requests()[2]); - assert!(continuation.contains("old global instructions")); - assert!(!continuation.contains("new global instructions")); + let requests = response_mock.requests(); + let expected_fragment = + expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[2], &expected_fragment); Ok(()) } @@ -365,7 +574,7 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> .await; let provider = local_compaction_provider(&server); let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), "old global instructions")?; + let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let mut initial_builder = test_codex().with_home(Arc::clone(&home)).with_config({ let provider = provider.clone(); move |config| config.model_provider = provider @@ -383,7 +592,7 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> }) .await; - std::fs::write(source.as_path(), "new global instructions")?; + std::fs::write(source.as_path(), NEW_GLOBAL_INSTRUCTIONS)?; let mut resume_builder = test_codex() .with_home(Arc::clone(&home)) .with_config(move |config| config.model_provider = provider); @@ -391,9 +600,9 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> .resume(&server, Arc::clone(&home), rollout_path) .await?; resumed.submit_turn("resume legacy history").await?; - let resumed_rendered = response_mock.requests()[1].body_json().to_string(); - assert!(resumed_rendered.contains("old global instructions")); - assert!(!resumed_rendered.contains("new global instructions")); + let requests = response_mock.requests(); + let old_fragment = expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[1], &old_fragment); resumed.codex.submit(Op::Compact).await?; wait_for_event(&resumed.codex, |event| { @@ -402,9 +611,9 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> .await; resumed.submit_turn("rebuild full context").await?; - let rebuilt = user_instructions(&response_mock.requests()[3]); - assert!(rebuilt.contains("new global instructions")); - assert!(!rebuilt.contains("old global instructions")); + let requests = response_mock.requests(); + let new_fragment = expected_instruction_fragment(&resumed.config.cwd, NEW_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[3], &new_fragment); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index b397464bf144..b5d6020bf029 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -8,14 +8,13 @@ use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; use tempfile::TempDir; +const PROJECT_INSTRUCTIONS: &str = "Project-specific test instructions"; + #[tokio::test] async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - std::fs::write( - codex_home.path().join("AGENTS.md"), - "Project-specific test instructions", - )?; + std::fs::write(codex_home.path().join("AGENTS.md"), PROJECT_INSTRUCTIONS)?; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { @@ -44,19 +43,23 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { phase: None, }; assert_eq!(input.last(), Some(&expected_user_message)); - assert!(input.iter().any(|item| { - let ResponseItem::Message { content, .. } = item else { - return false; - }; - - content.iter().any(|content_item| { - let (ContentItem::InputText { text } | ContentItem::OutputText { text }) = content_item - else { + assert!( + input.iter().any(|item| { + let ResponseItem::Message { content, .. } = item else { return false; }; - text.contains("Project-specific test instructions") - }) - })); + + content.iter().any(|content_item| { + let (ContentItem::InputText { text } | ContentItem::OutputText { text }) = + content_item + else { + return false; + }; + text.contains(PROJECT_INSTRUCTIONS) + }) + }), + "expected prompt input to contain {PROJECT_INSTRUCTIONS:?}; observed: {input:#?}" + ); Ok(()) } diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 659e2f133909..ca31a9bf2aca 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -440,11 +440,11 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { developer_contents .iter() .any(|content| content.contains("`sandbox_mode`")), - "expected permissions developer message, got {developer_contents:?}" + "expected developer contents to contain text with \"`sandbox_mode`\"; observed: {developer_contents:?}" ); assert!( developer_contents.contains(&"Foreshadow upcoming tool calls."), - "expected developer instructions in developer messages, got {developer_contents:?}" + "expected developer contents to contain \"Foreshadow upcoming tool calls.\"; observed: {developer_contents:?}" ); let user_contents = request["input"] .as_array() @@ -459,7 +459,7 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { user_contents .iter() .any(|content| content.contains("MCP global instructions")), - "expected CODEX_HOME instructions in user messages, got {user_contents:?}" + "expected user contents to contain text with \"MCP global instructions\"; observed: {user_contents:?}" ); Ok(()) From 370ee0584b72143fc47337d21933a41d1b4da7ef Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 16:49:46 -0700 Subject: [PATCH 03/14] test: rename global instructions suite --- ...global_instructions_regression.rs => global_instructions.rs} | 0 codex-rs/core/tests/suite/mod.rs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename codex-rs/core/tests/suite/{global_instructions_regression.rs => global_instructions.rs} (100%) diff --git a/codex-rs/core/tests/suite/global_instructions_regression.rs b/codex-rs/core/tests/suite/global_instructions.rs similarity index 100% rename from codex-rs/core/tests/suite/global_instructions_regression.rs rename to codex-rs/core/tests/suite/global_instructions.rs diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 780ee6b7909e..19c744176786 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -52,7 +52,7 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; -mod global_instructions_regression; +mod global_instructions; #[cfg(not(target_os = "windows"))] mod guardian_review; mod hierarchical_agents; From fc5b45bfc2212b4c34e03ab6e4f826feb09edd90 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 16:52:25 -0700 Subject: [PATCH 04/14] test: cover reviewed instruction edge cases --- .../app-server/tests/suite/v2/thread_fork.rs | 58 ++++++++++++++++++- .../core/tests/suite/global_instructions.rs | 15 +++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 1dc07963adeb..ad6665eaa2f2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -37,6 +37,7 @@ use codex_rollout::append_rollout_item_to_path; use codex_rollout::append_thread_name; use codex_rollout::read_session_meta_line; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -252,7 +253,21 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { #[tokio::test] async fn thread_fork_reloads_instruction_sources() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("Done").await; + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("parent-response"), + responses::ev_completed("parent-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("fork-response"), + responses::ev_completed("fork-response"), + ]), + ], + ) + .await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let global_agents = codex_home.path().join("AGENTS.md"); @@ -322,6 +337,7 @@ async fn thread_fork_reloads_instruction_sources() -> Result<()> { ) .await??; let ThreadForkResponse { + thread: forked_thread, instruction_sources, .. } = to_response::(fork_resp)?; @@ -332,6 +348,46 @@ async fn thread_fork_reloads_instruction_sources() -> Result<()> { "fork reloads sources after the files have been removed" ); + let fork_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: forked_thread.id, + input: vec![UserInput::Text { + text: "inspect inherited instructions".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let expected_instruction = format!( + "# AGENTS.md instructions for {}\n\n\nglobal instructions\n\n--- project-doc ---\n\nproject instructions\n", + workspace.path().display() + ); + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + for request in requests { + let instruction_fragments = request + .message_input_texts("user") + .into_iter() + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .collect::>(); + assert_eq!( + instruction_fragments, + vec![expected_instruction.clone()], + "parent and fork requests should contain the same historical instruction fragment" + ); + } + Ok(()) } diff --git a/codex-rs/core/tests/suite/global_instructions.rs b/codex-rs/core/tests/suite/global_instructions.rs index 8da620d23b96..b9fb68ead37f 100644 --- a/codex-rs/core/tests/suite/global_instructions.rs +++ b/codex-rs/core/tests/suite/global_instructions.rs @@ -176,6 +176,14 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> { let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("warning-response"), + responses::ev_completed("warning-response"), + ]), + ) + .await; let home = Arc::new(TempDir::new()?); let source = write_global(home.as_ref(), b"global\xFFinstructions")?; @@ -197,10 +205,17 @@ async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> warning.contains("invalid UTF-8"), "expected warning to contain \"invalid UTF-8\"; observed: {warning}" ); + test.submit_turn("inspect lossy global instructions") + .await?; + let expected_fragment = + expected_instruction_fragment(&test.config.cwd, "global\u{FFFD}instructions"); + assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); Ok(()) } +// TODO(anp): Align cold-resume instruction sources with the historical instructions replayed to +// the model so the API source list and model-visible context describe the same files. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cold_resume_replays_rendered_instructions_but_reports_current_config_sources() -> Result<()> { From 4ce0812b02ebf0973901ed19f58bb7852237163a Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 17:14:23 -0700 Subject: [PATCH 05/14] codex: fix Windows compaction parity path (#26830) --- codex-rs/core/tests/suite/compact_remote_parity.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index aff6d5a3d25c..7fba6fba4adc 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -25,6 +25,9 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +#[cfg(windows)] +const FIXED_CWD: &str = r"C:\tmp\codex_remote_compaction_parity_workspace"; +#[cfg(not(windows))] const FIXED_CWD: &str = "/tmp/codex_remote_compaction_parity_workspace"; const IMAGE_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; const SUMMARY: &str = "REMOTE_COMPACTION_PARITY_ENCRYPTED_SUMMARY"; From 98a6caf6a105156a0741c5952bd3350c03333556 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 17:21:20 -0700 Subject: [PATCH 06/14] codex: address PR review feedback (#26830) --- .../tests/suite/v2/thread_resume.rs | 67 ++++++++++++++++- .../core/tests/suite/global_instructions.rs | 74 ++++++++++++++++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 1336ee1b2322..36277c6a64a8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -334,7 +334,21 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul #[tokio::test] async fn thread_resume_cold_thread_reloads_instruction_sources() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("Done").await; + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("initial-response"), + responses::ev_completed("initial-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("resumed-response"), + responses::ev_completed("resumed-response"), + ]), + ], + ) + .await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let old_global_agents = codex_home.path().join("AGENTS.md"); @@ -361,7 +375,7 @@ async fn thread_resume_cold_thread_reloads_instruction_sources() -> Result<()> { .. } = to_response::(start_resp)?; let old_global_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(old_global_agents)?)?; - assert_eq!(instruction_sources, vec![old_global_agents]); + assert_eq!(instruction_sources, vec![old_global_agents.clone()]); let turn_id = first_mcp .send_turn_start_request(TurnStartParams { @@ -402,12 +416,61 @@ async fn thread_resume_cold_thread_reloads_instruction_sources() -> Result<()> { ) .await??; let ThreadResumeResponse { + thread: resumed_thread, instruction_sources, .. } = to_response::(resume_resp)?; let new_global_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(new_global_agents)?)?; assert_eq!(instruction_sources, vec![new_global_agents]); + let resumed_turn_id = second_mcp + .send_turn_start_request(TurnStartParams { + thread_id: resumed_thread.id, + input: vec![UserInput::Text { + text: "inspect resumed instructions".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + second_mcp.read_stream_until_response_message(RequestId::Integer(resumed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + second_mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let expected_instruction = format!( + "# AGENTS.md instructions for {}\n\n\nold global instructions\n", + workspace.path().display() + ); + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let initial_instruction_fragments = requests[0] + .message_input_texts("user") + .into_iter() + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .collect::>(); + assert_eq!( + initial_instruction_fragments, + vec![expected_instruction.clone()], + "initial model request should contain the creation-time instruction fragment" + ); + let resumed_instruction_fragments = requests[1] + .message_input_texts("user") + .into_iter() + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .collect::>(); + assert_eq!( + resumed_instruction_fragments, + vec![expected_instruction], + "cold-resumed model request should replay the creation-time instruction fragment" + ); + Ok(()) } diff --git a/codex-rs/core/tests/suite/global_instructions.rs b/codex-rs/core/tests/suite/global_instructions.rs index b9fb68ead37f..2fb1e5c3fbb1 100644 --- a/codex-rs/core/tests/suite/global_instructions.rs +++ b/codex-rs/core/tests/suite/global_instructions.rs @@ -189,6 +189,7 @@ async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> let mut builder = test_codex().with_home(home); let test = builder.build(&server).await?; + assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]); let warning = wait_for_event_match(&test.codex, |event| match event { EventMsg::Warning(warning) @@ -239,6 +240,11 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so let mut initial_builder = test_codex().with_home(Arc::clone(&home)); let initial = initial_builder.build(&server).await?; + assert_eq!( + initial.codex.instruction_sources().await, + vec![old_source.clone()], + "initial thread reports the creation-time global source" + ); initial.submit_turn("persist instructions").await?; let rollout_path = initial .session_configured @@ -304,6 +310,11 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let mut builder = test_codex().with_home(Arc::clone(&home)); let parent = builder.build(&server).await?; + assert_eq!( + parent.codex.instruction_sources().await, + vec![source.clone()], + "parent reports the creation-time global source" + ); parent.submit_turn("persist instructions").await?; parent.codex.ensure_rollout_materialized().await; parent.codex.flush_rollout().await?; @@ -388,7 +399,7 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() "message": SPAWN_CHILD_PROMPT, "fork_context": true, }))?; - responses::mount_sse_once_match( + let spawn_mock = responses::mount_sse_once_match( &server, |request: &wiremock::Request| request_body_contains(request, SPAWN_PARENT_PROMPT), responses::sse(vec![ @@ -428,7 +439,7 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() .await; let home = Arc::new(TempDir::new()?); - write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; + let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let mut builder = test_codex() .with_home(Arc::clone(&home)) .with_config(|config| { @@ -436,11 +447,18 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() let _ = config.features.disable(Feature::EnableRequestCompression); }); let test = builder.build(&server).await?; + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "parent reports the creation-time global source before spawning" + ); test.submit_turn(SPAWN_SEED_PROMPT).await?; let seed_request = seed_mock.single_request(); - write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_ne!(source, new_source); test.submit_turn(SPAWN_PARENT_PROMPT).await?; + let spawn_request = spawn_mock.single_request(); let child_request = tokio::time::timeout(Duration::from_secs(10), async { loop { if let Some(request) = child_mock.requests().into_iter().next() { @@ -455,7 +473,13 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() let expected_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); assert_single_instruction_fragment(&seed_request, &expected_fragment); + assert_single_instruction_fragment(&spawn_request, &expected_fragment); assert_single_instruction_fragment(&child_request, &expected_fragment); + assert_eq!( + test.codex.instruction_sources().await, + vec![source], + "running parent retains the creation-time global source after spawning" + ); let seed_input = seed_request.input(); let child_input = child_request.input(); assert_eq!( @@ -498,6 +522,11 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu config.model_provider = provider; }); let test = builder.build(&server).await?; + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "thread reports the creation-time global source before compaction" + ); test.submit_turn("first turn").await?; std::fs::write(source.as_path(), NEW_GLOBAL_INSTRUCTIONS)?; @@ -510,9 +539,17 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu test.submit_turn("after compact").await?; let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); let expected_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &expected_fragment); + assert_single_instruction_fragment(&requests[1], &expected_fragment); assert_single_instruction_fragment(&requests[2], &expected_fragment); + assert_eq!( + test.codex.instruction_sources().await, + vec![source], + "thread retains the creation-time global source after compaction" + ); Ok(()) } @@ -549,14 +586,27 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re config.model_auto_compact_token_limit = Some(90); }); let test = builder.build(&server).await?; + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "thread reports the creation-time global source before mid-turn compaction" + ); std::fs::write(source.as_path(), NEW_GLOBAL_INSTRUCTIONS)?; test.submit_turn("trigger mid-turn compaction").await?; let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); let expected_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &expected_fragment); + assert_single_instruction_fragment(&requests[1], &expected_fragment); assert_single_instruction_fragment(&requests[2], &expected_fragment); + assert_eq!( + test.codex.instruction_sources().await, + vec![source], + "thread retains the creation-time global source after mid-turn compaction" + ); Ok(()) } @@ -595,6 +645,11 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> move |config| config.model_provider = provider }); let initial = initial_builder.build(&server).await?; + assert_eq!( + initial.codex.instruction_sources().await, + vec![source.clone()], + "initial thread reports the creation-time global source" + ); initial.submit_turn("persist legacy history").await?; let rollout_path = initial .session_configured @@ -614,9 +669,15 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> let resumed = resume_builder .resume(&server, Arc::clone(&home), rollout_path) .await?; + assert_eq!( + resumed.codex.instruction_sources().await, + vec![source.clone()], + "resumed thread reports the current global source" + ); resumed.submit_turn("resume legacy history").await?; let requests = response_mock.requests(); let old_fragment = expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &old_fragment); assert_single_instruction_fragment(&requests[1], &old_fragment); resumed.codex.submit(Op::Compact).await?; @@ -627,8 +688,15 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> resumed.submit_turn("rebuild full context").await?; let requests = response_mock.requests(); + assert_eq!(requests.len(), 4); let new_fragment = expected_instruction_fragment(&resumed.config.cwd, NEW_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[2], &old_fragment); assert_single_instruction_fragment(&requests[3], &new_fragment); + assert_eq!( + resumed.codex.instruction_sources().await, + vec![source], + "resumed thread retains the current global source after compaction" + ); Ok(()) } From 4e2a69b9a3e0cf97209eeab7551a7bb11d5bec01 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 17:28:51 -0700 Subject: [PATCH 07/14] test: document fork source mismatch (#26830) --- codex-rs/core/tests/suite/global_instructions.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/tests/suite/global_instructions.rs b/codex-rs/core/tests/suite/global_instructions.rs index 2fb1e5c3fbb1..71932d16a4fb 100644 --- a/codex-rs/core/tests/suite/global_instructions.rs +++ b/codex-rs/core/tests/suite/global_instructions.rs @@ -289,6 +289,8 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so Ok(()) } +// TODO(anp): Align fork instruction sources with the historical instructions replayed to the +// model so the reported source list and model-visible context describe the same files. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> { let server = responses::start_mock_server().await; From 01f4a200d4e6eb53d086ec44ebddff9f25b1f8b6 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 17:36:29 -0700 Subject: [PATCH 08/14] test: exhaustively characterize instruction mutations (#26830) --- .../core/tests/suite/global_instructions.rs | 123 +++++++++++++++--- 1 file changed, 106 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/tests/suite/global_instructions.rs b/codex-rs/core/tests/suite/global_instructions.rs index 71932d16a4fb..ced60f6e5d71 100644 --- a/codex-rs/core/tests/suite/global_instructions.rs +++ b/codex-rs/core/tests/suite/global_instructions.rs @@ -25,6 +25,7 @@ const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; const GLOBAL_INSTRUCTIONS: &str = "global instructions"; const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; +const NEW_PROJECT_INSTRUCTIONS: &str = "new project instructions"; const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; const PROJECT_INSTRUCTIONS: &str = "project instructions"; const PROJECT_SEPARATOR: &str = "--- project-doc ---"; @@ -97,6 +98,7 @@ fn local_compaction_provider(server: &wiremock::MockServer) -> ModelProviderInfo #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { + // Set up one global source, one project source, and two ordinary model turns. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( &server, @@ -127,21 +129,39 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re Ok(()) }); let test = builder.build_with_remote_env(&server).await?; + let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME); + let creation_sources = vec![global_source, project_source]; - assert_eq!( - test.codex.instruction_sources().await, - vec![global_source, test.config.cwd.join("AGENTS.md")] - ); + // Confirm the thread records both creation-time sources in composition order. + assert_eq!(test.codex.instruction_sources().await, creation_sources); + // Materialize the initial snapshot, then add global and project overrides before another turn. test.submit_turn("first turn").await?; + let new_global_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + let new_project_source = test.config.cwd.join(GLOBAL_AGENTS_OVERRIDE_FILENAME); + test.fs() + .write_file( + &new_project_source, + NEW_PROJECT_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + let replacement_sources = vec![new_global_source, new_project_source]; + assert_ne!( + creation_sources, replacement_sources, + "the mutation should change both resolved source paths" + ); test.submit_turn("second turn").await?; + // Assert the running thread keeps its original sources, rendering, and structured prefix. let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); let expected_contents = format!("{GLOBAL_INSTRUCTIONS}\n\n{PROJECT_SEPARATOR}\n\n{PROJECT_INSTRUCTIONS}"); let expected_fragment = expected_instruction_fragment(&test.config.cwd, &expected_contents); let fragments = instruction_fragments(&requests[0]); - assert_eq!(fragments, vec![expected_fragment]); + assert_eq!(fragments, vec![expected_fragment.clone()]); + assert_single_instruction_fragment(&requests[1], &expected_fragment); let rendered = fragments .into_iter() .next() @@ -165,8 +185,15 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re "expected rendered instructions to contain {PROJECT_SEPARATOR:?}; observed: {rendered}" ); assert_eq!( - &requests[1].input()[..requests[0].input().len()], - requests[0].input(), + test.codex.instruction_sources().await, + creation_sources, + "ordinary turns retain the creation-time source list" + ); + let first_input = requests[0].input(); + let second_input = requests[1].input(); + assert_eq!( + second_input.get(..first_input.len()), + Some(first_input.as_slice()), "the ordinary second turn should retain the cached prefix" ); @@ -175,6 +202,7 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> { + // Set up a malformed global instruction file and one model response. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_once( &server, @@ -187,10 +215,9 @@ async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> let home = Arc::new(TempDir::new()?); let source = write_global(home.as_ref(), b"global\xFFinstructions")?; + // Create the thread, capture its load warning, and submit one turn for rendered output. let mut builder = test_codex().with_home(home); let test = builder.build(&server).await?; - assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]); - let warning = wait_for_event_match(&test.codex, |event| match event { EventMsg::Warning(warning) if warning @@ -202,12 +229,15 @@ async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> _ => None, }) .await; + test.submit_turn("inspect lossy global instructions") + .await?; + + // Assert the source is reported, the warning is specific, and rendering is lossily decoded. + assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]); assert!( warning.contains("invalid UTF-8"), "expected warning to contain \"invalid UTF-8\"; observed: {warning}" ); - test.submit_turn("inspect lossy global instructions") - .await?; let expected_fragment = expected_instruction_fragment(&test.config.cwd, "global\u{FFFD}instructions"); assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); @@ -220,6 +250,7 @@ async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cold_resume_replays_rendered_instructions_but_reports_current_config_sources() -> Result<()> { + // Set up an initial turn and a later cold-resumed turn against the same rollout. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( &server, @@ -238,8 +269,11 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so let home = Arc::new(TempDir::new()?); let old_source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; + // Create the initial thread and persist its creation-time instruction snapshot. let mut initial_builder = test_codex().with_home(Arc::clone(&home)); let initial = initial_builder.build(&server).await?; + + // Assert the pre-resume thread reports the source used to create its snapshot. assert_eq!( initial.codex.instruction_sources().await, vec![old_source.clone()], @@ -257,6 +291,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so }) .await; + // Add a preferred override source, then cold-resume with freshly loaded configuration. let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; assert_ne!(old_source, new_source); let mut resume_builder = test_codex().with_home(Arc::clone(&home)); @@ -264,6 +299,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so .resume(&server, Arc::clone(&home), rollout_path) .await?; + // Assert the API reports the new source while model history replays the old structured prefix. assert_eq!( resumed.codex.instruction_sources().await, vec![new_source], @@ -293,6 +329,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so // model so the reported source list and model-visible context describe the same files. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> { + // Set up a parent turn and a later fork turn against the parent's rollout. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( &server, @@ -310,8 +347,12 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> .await; let home = Arc::new(TempDir::new()?); let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; + + // Create the parent and persist its creation-time instruction snapshot. let mut builder = test_codex().with_home(Arc::clone(&home)); let parent = builder.build(&server).await?; + + // Assert the parent reports the source used to create its snapshot. assert_eq!( parent.codex.instruction_sources().await, vec![source.clone()], @@ -322,6 +363,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> parent.codex.flush_rollout().await?; let rollout_path = parent.codex.rollout_path().expect("rollout path"); + // Add a preferred override source, then fork with freshly loaded configuration. let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; assert_ne!(source, new_source); let mut fork_config = load_default_config_for_test(home.as_ref()).await; @@ -340,6 +382,8 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> /*parent_trace*/ None, ) .await?; + + // Assert the fork reports the new source before issuing its first turn. assert_eq!( forked.thread.instruction_sources().await, vec![new_source], @@ -365,6 +409,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> }) .await; + // Assert the forked model request replays the parent's exact structured history. let requests = response_mock.requests(); assert_eq!(requests.len(), 2); let parent_input = requests[0].input(); @@ -386,6 +431,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() -> Result<()> { skip_if_no_network!(Ok(())); + // Set up matched responses for the parent seed, spawn call, child turn, and parent follow-up. let server = responses::start_mock_server().await; let seed_mock = responses::mount_sse_once_match( &server, @@ -440,6 +486,7 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() ) .await; + // Create the parent thread, record its source, and seed the history inherited by the child. let home = Arc::new(TempDir::new()?); let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let mut builder = test_codex() @@ -449,6 +496,8 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() let _ = config.features.disable(Feature::EnableRequestCompression); }); let test = builder.build(&server).await?; + + // Assert the parent reports the creation-time source before spawning. assert_eq!( test.codex.instruction_sources().await, vec![source.clone()], @@ -457,9 +506,15 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() test.submit_turn(SPAWN_SEED_PROMPT).await?; let seed_request = seed_mock.single_request(); + // Add a preferred override, then spawn a full-history child while observing its thread ID. let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; assert_ne!(source, new_source); + let mut created_threads = test.thread_manager.subscribe_thread_created(); test.submit_turn(SPAWN_PARENT_PROMPT).await?; + let child_thread_id = tokio::time::timeout(Duration::from_secs(10), created_threads.recv()) + .await + .map_err(|_| anyhow!("timed out waiting for the forked subagent thread"))??; + let child_thread = test.thread_manager.get_thread(child_thread_id).await?; let spawn_request = spawn_mock.single_request(); let child_request = tokio::time::timeout(Duration::from_secs(10), async { loop { @@ -472,6 +527,7 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() .await .map_err(|_| anyhow!("timed out waiting for the forked subagent request"))?; + // Assert parent and child report and render the inherited creation-time snapshot exactly once. let expected_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); assert_single_instruction_fragment(&seed_request, &expected_fragment); @@ -479,9 +535,14 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() assert_single_instruction_fragment(&child_request, &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source], + vec![source.clone()], "running parent retains the creation-time global source after spawning" ); + assert_eq!( + child_thread.instruction_sources().await, + vec![source], + "forked subagent reports the inherited creation-time source" + ); let seed_input = seed_request.input(); let child_input = child_request.input(); assert_eq!( @@ -495,6 +556,7 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { + // Set up an initial turn, a manual compaction response, and a post-compaction turn. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( &server, @@ -518,20 +580,26 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu let home = Arc::new(TempDir::new()?); let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let provider = local_compaction_provider(&server); + + // Create the thread with the old global source loaded into its instruction snapshot. let mut builder = test_codex() .with_home(Arc::clone(&home)) .with_config(move |config| { config.model_provider = provider; }); let test = builder.build(&server).await?; + + // Assert the pre-compaction source list points at the creation-time file. assert_eq!( test.codex.instruction_sources().await, vec![source.clone()], "thread reports the creation-time global source before compaction" ); + // Materialize the old snapshot, add a preferred override, and manually compact the thread. test.submit_turn("first turn").await?; - std::fs::write(source.as_path(), NEW_GLOBAL_INSTRUCTIONS)?; + let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_ne!(source, new_source); test.codex.submit(Op::Compact).await?; wait_for_event(&test.codex, |event| { @@ -540,6 +608,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu .await; test.submit_turn("after compact").await?; + // Assert every phase keeps the old rendering and the thread keeps the old reported source. let requests = response_mock.requests(); assert_eq!(requests.len(), 3); let expected_fragment = @@ -558,6 +627,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { + // Set up a turn that crosses the auto-compaction limit and a post-compaction response. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( &server, @@ -580,6 +650,8 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re let home = Arc::new(TempDir::new()?); let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; let provider = local_compaction_provider(&server); + + // Create the thread with the old global source loaded into its instruction snapshot. let mut builder = test_codex() .with_home(Arc::clone(&home)) .with_config(move |config| { @@ -588,15 +660,20 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re config.model_auto_compact_token_limit = Some(90); }); let test = builder.build(&server).await?; + + // Assert the pre-compaction source list points at the creation-time file. assert_eq!( test.codex.instruction_sources().await, vec![source.clone()], "thread reports the creation-time global source before mid-turn compaction" ); - std::fs::write(source.as_path(), NEW_GLOBAL_INSTRUCTIONS)?; + // Add a preferred override before the turn triggers automatic mid-turn compaction. + let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_ne!(source, new_source); test.submit_turn("trigger mid-turn compaction").await?; + // Assert the initial, compact, and resumed requests all keep the old snapshot and source. let requests = response_mock.requests(); assert_eq!(requests.len(), 3); let expected_fragment = @@ -613,8 +690,11 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re Ok(()) } +// TODO(anp): Align legacy-resume instruction sources with the historical instructions replayed +// before compaction so the reported source list and model-visible context describe the same files. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> Result<()> { + // Set up initial, resumed, manual-compaction, and rebuilt-context model responses. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( &server, @@ -642,11 +722,15 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> let provider = local_compaction_provider(&server); let home = Arc::new(TempDir::new()?); let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; + + // Create the initial thread and persist the old instruction snapshot to its rollout. let mut initial_builder = test_codex().with_home(Arc::clone(&home)).with_config({ let provider = provider.clone(); move |config| config.model_provider = provider }); let initial = initial_builder.build(&server).await?; + + // Assert the initial thread reports the source used for its historical snapshot. assert_eq!( initial.codex.instruction_sources().await, vec![source.clone()], @@ -664,16 +748,19 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> }) .await; - std::fs::write(source.as_path(), NEW_GLOBAL_INSTRUCTIONS)?; + // Add a preferred override, then cold-resume with the new source in current configuration. + let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_ne!(source, new_source); let mut resume_builder = test_codex() .with_home(Arc::clone(&home)) .with_config(move |config| config.model_provider = provider); let resumed = resume_builder .resume(&server, Arc::clone(&home), rollout_path) .await?; + // Assert resume reports the new source while the first resumed turn replays old history. assert_eq!( resumed.codex.instruction_sources().await, - vec![source.clone()], + vec![new_source.clone()], "resumed thread reports the current global source" ); resumed.submit_turn("resume legacy history").await?; @@ -682,6 +769,7 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> assert_single_instruction_fragment(&requests[0], &old_fragment); assert_single_instruction_fragment(&requests[1], &old_fragment); + // Compact the resumed thread so legacy reconstruction can inject the current configuration. resumed.codex.submit(Op::Compact).await?; wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -689,6 +777,7 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> .await; resumed.submit_turn("rebuild full context").await?; + // Assert compaction sees old history, then the rebuilt context uses the new source contents. let requests = response_mock.requests(); assert_eq!(requests.len(), 4); let new_fragment = expected_instruction_fragment(&resumed.config.cwd, NEW_GLOBAL_INSTRUCTIONS); @@ -696,7 +785,7 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> assert_single_instruction_fragment(&requests[3], &new_fragment); assert_eq!( resumed.codex.instruction_sources().await, - vec![source], + vec![new_source], "resumed thread retains the current global source after compaction" ); From 6c6bc03418b575dba5af860025449bca73ecf1be Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 6 Jun 2026 18:47:49 -0700 Subject: [PATCH 09/14] test: expand global instruction characterization (#26830) --- .../core/tests/suite/compact_remote_parity.rs | 328 +++++++++++- .../core/tests/suite/global_instructions.rs | 483 ++++++++++++++++-- 2 files changed, 737 insertions(+), 74 deletions(-) diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 7fba6fba4adc..ccf275cee3b6 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -1,6 +1,7 @@ #![allow(clippy::expect_used)] use std::fs; +use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; @@ -18,6 +19,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses; use core_test_support::responses::ResponseMock; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -108,6 +110,13 @@ struct Capture { compact_requests: usize, } +#[derive(Debug)] +struct PersistedResumeCapture { + compact_body: Value, + replacement_history: Value, + resumed_body: Value, +} + const ASSISTANT_ONLY: &[Step] = &[Step::Assistant]; const REASONING_IMAGE: &[Step] = &[Step::ReasoningAssistant, Step::ImageAssistant]; const TOOL_MIX: &[Step] = &[Step::Assistant, Step::FunctionTool, Step::ShellTool]; @@ -177,7 +186,12 @@ async fn remote_compaction_parity_v2_api_key_sends_service_tier_upgrade() -> Res ); assert_compact_requests_eq_except_v2_service_tier("api-key service tier", &legacy, &v2); - assert_follow_up_and_history_eq("api-key service tier", &legacy, &v2); + assert_follow_up_and_history_eq( + "api-key service tier", + &legacy, + &v2, + /*replacement_instruction_messages*/ 0, + ); Ok(()) } @@ -197,7 +211,12 @@ async fn remote_compaction_parity_pre_turn_auto() -> Result<()> { let legacy = run_pre_turn_auto_session(Mode::Legacy).await?; let v2 = run_pre_turn_auto_session(Mode::V2).await?; - assert_capture_eq("pre-turn auto", &legacy, &v2); + assert_capture_eq( + "pre-turn auto", + &legacy, + &v2, + /*replacement_instruction_messages*/ 0, + ); Ok(()) } @@ -207,18 +226,75 @@ async fn remote_compaction_parity_mid_turn_auto() -> Result<()> { let legacy = run_mid_turn_auto_session(Mode::Legacy).await?; let v2 = run_mid_turn_auto_session(Mode::V2).await?; - assert_capture_eq("mid-turn auto", &legacy, &v2); + assert_capture_eq( + "mid-turn auto", + &legacy, + &v2, + /*replacement_instruction_messages*/ 1, + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_persisted_history_cold_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + + let legacy = run_persisted_history_resume(Mode::Legacy).await?; + let v2 = run_persisted_history_resume(Mode::V2).await?; + + assert_single_user_instruction_message( + &legacy.compact_body, + "legacy compact request before cold resume", + ); + assert_single_user_instruction_message( + &v2.compact_body, + "v2 compact request before cold resume", + ); + assert_user_instruction_messages_in_items( + &legacy.replacement_history, + "legacy persisted replacement history", + /*expected_count*/ 0, + ); + assert_user_instruction_messages_in_items( + &v2.replacement_history, + "v2 persisted replacement history", + /*expected_count*/ 0, + ); + assert_single_user_instruction_message(&legacy.resumed_body, "legacy cold-resumed request"); + assert_single_user_instruction_message(&v2.resumed_body, "v2 cold-resumed request"); + + assert_json_eq( + "cold-resume replacement history parity mismatch", + &legacy.replacement_history, + &v2.replacement_history, + ); + assert_json_eq( + "cold-resume request parity mismatch", + &follow_up_request_view(&legacy.resumed_body), + &follow_up_request_view(&v2.resumed_body), + ); + Ok(()) } async fn compare_manual_scenario(scenario: &Scenario, settings: RunSettings) -> Result<()> { let legacy = run_manual_session(scenario, Mode::Legacy, settings).await?; let v2 = run_manual_session(scenario, Mode::V2, settings).await?; - assert_capture_eq(scenario.name, &legacy, &v2); + assert_capture_eq( + scenario.name, + &legacy, + &v2, + /*replacement_instruction_messages*/ 0, + ); Ok(()) } -fn assert_capture_eq(label: &str, legacy: &Capture, v2: &Capture) { +fn assert_capture_eq( + label: &str, + legacy: &Capture, + v2: &Capture, + replacement_instruction_messages: usize, +) { assert_eq!( legacy.compact_requests, 1, "legacy compact endpoint should be called exactly once for {label}", @@ -235,6 +311,14 @@ fn assert_capture_eq(label: &str, legacy: &Capture, v2: &Capture) { &legacy_compact, &v2_compact, ); + assert_single_user_instruction_message( + &legacy.compact_body, + &format!("legacy compact request for {label}"), + ); + assert_single_user_instruction_message( + &v2.compact_body, + &format!("v2 compact request for {label}"), + ); let legacy_follow_up = follow_up_request_view(&legacy.follow_up_body); let v2_follow_up = follow_up_request_view(&v2.follow_up_body); @@ -257,6 +341,16 @@ fn assert_capture_eq(label: &str, legacy: &Capture, v2: &Capture) { &legacy.replacement_history, &v2.replacement_history, ); + assert_user_instruction_messages_in_items( + &legacy.replacement_history, + &format!("legacy replacement history for {label}"), + replacement_instruction_messages, + ); + assert_user_instruction_messages_in_items( + &v2.replacement_history, + &format!("v2 replacement history for {label}"), + replacement_instruction_messages, + ); println!( "PARITY_OK scenario={} normal_response_requests={} compact_input_items={} replacement_history_items={} follow_up_input_items={}", @@ -286,9 +380,22 @@ fn assert_compact_requests_eq_except_v2_service_tier(label: &str, legacy: &Captu &legacy_compact, &v2_compact, ); + assert_single_user_instruction_message( + &legacy.compact_body, + &format!("legacy compact request for {label}"), + ); + assert_single_user_instruction_message( + &v2.compact_body, + &format!("v2 compact request for {label}"), + ); } -fn assert_follow_up_and_history_eq(label: &str, legacy: &Capture, v2: &Capture) { +fn assert_follow_up_and_history_eq( + label: &str, + legacy: &Capture, + v2: &Capture, + replacement_instruction_messages: usize, +) { let legacy_follow_up = follow_up_request_view(&legacy.follow_up_body); let v2_follow_up = follow_up_request_view(&v2.follow_up_body); assert_json_eq( @@ -310,6 +417,16 @@ fn assert_follow_up_and_history_eq(label: &str, legacy: &Capture, v2: &Capture) &legacy.replacement_history, &v2.replacement_history, ); + assert_user_instruction_messages_in_items( + &legacy.replacement_history, + &format!("legacy replacement history for {label}"), + replacement_instruction_messages, + ); + assert_user_instruction_messages_in_items( + &v2.replacement_history, + &format!("v2 replacement history for {label}"), + replacement_instruction_messages, + ); } async fn run_manual_session( @@ -465,6 +582,92 @@ async fn run_mid_turn_auto_session(mode: Mode) -> Result { .await } +async fn run_persisted_history_resume(mode: Mode) -> Result { + let response_bodies = match mode { + Mode::Legacy => vec![ + responses::sse(vec![ + responses::ev_assistant_message( + "persisted-history-first-message", + "PERSISTED_HISTORY_FIRST_REPLY", + ), + responses::ev_completed("persisted-history-first-response"), + ]), + after_compact_response_body("persisted_history_resume"), + ], + Mode::V2 => vec![ + responses::sse(vec![ + responses::ev_assistant_message( + "persisted-history-first-message", + "PERSISTED_HISTORY_FIRST_REPLY", + ), + responses::ev_completed("persisted-history-first-response"), + ]), + compaction_v2_response_body(), + after_compact_response_body("persisted_history_resume"), + ], + }; + let settings = RunSettings::default(); + let harness = build_harness(mode, settings, /*hooks*/ false).await?; + let rollout_path = rollout_path(&harness); + let home = harness.test().home.clone(); + let codex = harness.test().codex.clone(); + let responses_mock = responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: "persisted history before compact".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + codex.submit(Op::Shutdown).await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + let response_requests = responses_mock.requests(); + let compact_body = match (mode, compact_mock.as_ref()) { + (Mode::Legacy, Some(compact_mock)) => compact_mock.single_request().body_json(), + (Mode::V2, None) => response_requests + .last() + .expect("v2 compact request should be present") + .body_json(), + (Mode::Legacy, None) | (Mode::V2, Some(_)) => panic!("unexpected compact mock state"), + }; + let replacement_history = replacement_history_from_rollout(&rollout_path)?; + + let mut resume_builder = parity_builder(mode, settings, /*hooks*/ false); + let resumed = resume_builder + .resume(harness.server(), home, rollout_path) + .await?; + submit_user_input( + &resumed.codex, + vec![UserInput::Text { + text: "persisted history after cold resume".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + let resumed_body = responses_mock + .requests() + .last() + .expect("cold-resumed request should be present") + .body_json(); + resumed.codex.submit(Op::Shutdown).await?; + wait_for_event(&resumed.codex, |ev| { + matches!(ev, EventMsg::ShutdownComplete) + }) + .await; + + Ok(PersistedResumeCapture { + compact_body, + replacement_history, + resumed_body, + }) +} + async fn run_manual_hook_session(mode: Mode) -> Result { let response_bodies = match mode { Mode::Legacy => vec![responses::sse(vec![ @@ -528,12 +731,20 @@ async fn build_harness_inner( hooks: bool, auto_compact_limit: Option, ) -> Result { - fs::create_dir_all(FIXED_CWD)?; + prepare_fixed_cwd()?; + TestCodexHarness::with_builder( + parity_builder(mode, settings, hooks) + .with_config(move |config| config.model_auto_compact_token_limit = auto_compact_limit), + ) + .await +} + +fn parity_builder(mode: Mode, settings: RunSettings, hooks: bool) -> TestCodexBuilder { let mut builder = test_codex().with_auth(settings.auth.build()); if hooks { builder = builder.with_pre_build_hook(write_manual_compact_hooks); } - TestCodexHarness::with_builder(builder.with_config(move |config| { + builder.with_config(move |config| { config.cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(PathBuf::from( FIXED_CWD, )) @@ -543,15 +754,25 @@ async fn build_harness_inner( if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); } - config.model_auto_compact_token_limit = auto_compact_limit; if hooks { trust_discovered_hooks(config); } if mode == Mode::V2 { let _ = config.features.enable(Feature::RemoteCompactionV2); } - })) - .await + }) +} + +fn prepare_fixed_cwd() -> Result<()> { + fs::create_dir_all(FIXED_CWD)?; + for filename in ["AGENTS.md", "AGENTS.override.md"] { + match fs::remove_file(Path::new(FIXED_CWD).join(filename)) { + Ok(()) => {} + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + } + Ok(()) } fn rollout_path(harness: &TestCodexHarness) -> PathBuf { @@ -795,9 +1016,24 @@ fn follow_up_request_view(body: &Value) -> Value { } fn assert_single_user_instruction_message(body: &Value, label: &str) { - let instruction_messages = body + let input = body .get("input") .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + assert_user_instruction_messages_in_items( + &Value::Array(input), + label, + /*expected_count*/ 1, + ); +} + +fn assert_user_instruction_messages_in_items(items: &Value, label: &str, expected_count: usize) { + let expected_text = format!( + "# AGENTS.md instructions for {FIXED_CWD}\n\n\n{USER_INSTRUCTIONS}\n" + ); + let instruction_messages = items + .as_array() .into_iter() .flatten() .filter(|item| { @@ -806,38 +1042,74 @@ fn assert_single_user_instruction_message(body: &Value, label: &str) { }) .filter_map(|item| { let content = item.get("content").and_then(Value::as_array)?; - let instruction_content = content + content .iter() - .filter(|span| { + .any(|span| { span.get("type").and_then(Value::as_str) == Some("input_text") && span .get("text") .and_then(Value::as_str) .is_some_and(|text| text.starts_with("# AGENTS.md instructions for ")) }) - .cloned() - .collect::>(); - (!instruction_content.is_empty()).then(|| { - json!({ - "role": item["role"].clone(), - "instruction_content": instruction_content, + .then(|| { + json!({ + "role": item["role"].clone(), + "content": item["content"].clone(), + }) }) - }) }) .collect::>(); - let expected_text = format!( - "# AGENTS.md instructions for {FIXED_CWD}\n\n\n{USER_INSTRUCTIONS}\n" + if expected_count == 0 { + assert_eq!( + instruction_messages, + Vec::::new(), + "{label} should omit global-instruction messages" + ); + return; + } + assert_eq!( + expected_count, 1, + "instruction assertion helper only supports exact counts of zero or one" ); + let expected_content = instruction_messages + .first() + .and_then(|message| message.get("content")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|span| { + span.get("type").and_then(Value::as_str) == Some("input_text") + && span + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| { + text == expected_text + || (text.starts_with("") + && text.ends_with("")) + }) + }) + .cloned() + .collect::>(); assert_eq!( instruction_messages, vec![json!({ "role": "user", - "instruction_content": [{ - "type": "input_text", - "text": expected_text, - }], + "content": expected_content, })], - "{label} should contain exactly one structured user-instruction message" + "{label} should contain exactly one dedicated contextual user-instruction message" + ); + assert_eq!( + instruction_messages[0]["content"] + .as_array() + .into_iter() + .flatten() + .filter(|span| { + span.get("type").and_then(Value::as_str) == Some("input_text") + && span.get("text").and_then(Value::as_str) == Some(expected_text.as_str()) + }) + .count(), + 1, + "{label} should contain the exact instruction span once" ); } diff --git a/codex-rs/core/tests/suite/global_instructions.rs b/codex-rs/core/tests/suite/global_instructions.rs index ced60f6e5d71..636a79bd1150 100644 --- a/codex-rs/core/tests/suite/global_instructions.rs +++ b/codex-rs/core/tests/suite/global_instructions.rs @@ -1,3 +1,5 @@ +use std::fs; +use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -5,10 +7,13 @@ use anyhow::Result; use anyhow::anyhow; use codex_core::ForkSnapshot; use codex_features::Feature; +use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::load_default_config_for_test; @@ -18,6 +23,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use tempfile::TempDir; @@ -29,8 +35,10 @@ const NEW_PROJECT_INSTRUCTIONS: &str = "new project instructions"; const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; const PROJECT_INSTRUCTIONS: &str = "project instructions"; const PROJECT_SEPARATOR: &str = "--- project-doc ---"; +const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; const SPAWN_CALL_ID: &str = "spawn-global-instructions-child"; const SPAWN_CHILD_PROMPT: &str = "inspect inherited global instructions"; +const SPAWN_FRESH_PARENT_PROMPT: &str = "spawn a child with fresh context"; const SPAWN_PARENT_PROMPT: &str = "spawn a child with the parent context"; const SPAWN_SEED_PROMPT: &str = "seed parent history"; @@ -60,6 +68,21 @@ fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { .collect() } +fn instruction_fragments_in_items(items: &[Value]) -> Vec { + items + .iter() + .filter(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user") + }) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|span| span.get("text").and_then(Value::as_str)) + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .map(str::to_string) + .collect() +} + fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { let cwd = cwd.as_path().display(); format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") @@ -69,6 +92,67 @@ fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, exp assert_eq!(instruction_fragments(request), vec![expected.to_string()]); } +fn replacement_history_from_rollout(path: &Path) -> Result> { + let rollout_text = fs::read_to_string(path)?; + let mut replacement_history = None; + for line in rollout_text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let entry: RolloutLine = serde_json::from_str(line)?; + if let RolloutItem::Compacted(compacted) = entry.item + && let Some(items) = compacted.replacement_history + { + replacement_history = Some( + items + .into_iter() + .map(serde_json::to_value) + .collect::, _>>()?, + ); + } + } + replacement_history.ok_or_else(|| anyhow!("expected rollout replacement history")) +} + +fn rewrite_compaction_as_legacy(path: &Path) -> Result<()> { + let rollout_text = fs::read_to_string(path)?; + let mut rewritten = Vec::new(); + let mut compacted_items = 0; + for line in rollout_text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let mut entry: RolloutLine = serde_json::from_str(line)?; + if let RolloutItem::Compacted(compacted) = &mut entry.item { + compacted.replacement_history = None; + compacted_items += 1; + } + rewritten.push(serde_json::to_string(&entry)?); + } + if compacted_items != 1 { + return Err(anyhow!( + "expected exactly one compaction to rewrite as legacy; observed {compacted_items}" + )); + } + fs::write(path, format!("{}\n", rewritten.join("\n")))?; + Ok(()) +} + +fn remote_v2_compaction_response() -> String { + responses::sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": REMOTE_V2_SUMMARY, + } + }), + responses::ev_completed("remote-v2-compact-response"), + ]) +} + fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { let is_zstd = request .headers @@ -130,30 +214,30 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re }); let test = builder.build_with_remote_env(&server).await?; let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME); - let creation_sources = vec![global_source, project_source]; + let creation_sources = vec![global_source.clone(), project_source.clone()]; // Confirm the thread records both creation-time sources in composition order. assert_eq!(test.codex.instruction_sources().await, creation_sources); - // Materialize the initial snapshot, then add global and project overrides before another turn. + // Materialize the initial snapshot, then rewrite both selected files in place before another + // ordinary turn. test.submit_turn("first turn").await?; - let new_global_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - let new_project_source = test.config.cwd.join(GLOBAL_AGENTS_OVERRIDE_FILENAME); + let rewritten_global_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; test.fs() .write_file( - &new_project_source, + &project_source, NEW_PROJECT_INSTRUCTIONS.as_bytes().to_vec(), /*sandbox*/ None, ) .await?; - let replacement_sources = vec![new_global_source, new_project_source]; - assert_ne!( - creation_sources, replacement_sources, - "the mutation should change both resolved source paths" + assert_eq!( + rewritten_global_source, global_source, + "same-path mutation should retain the selected global source path" ); test.submit_turn("second turn").await?; - // Assert the running thread keeps its original sources, rendering, and structured prefix. + // Assert the running thread keeps its original rendering and structured prefix even though + // both files at the reported source paths now contain different text. let requests = response_mock.requests(); assert_eq!(requests.len(), 2); let expected_contents = @@ -200,6 +284,47 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn global_instruction_context_item_is_currently_not_limited_by_project_doc_budget() +-> Result<()> { + // Set a one-byte project-doc budget, then create a much larger global instruction file. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("oversized-global-response"), + responses::ev_completed("oversized-global-response"), + ]), + ) + .await; + let home = Arc::new(TempDir::new()?); + let oversized_global = vec!["global instruction item remains uncapped"; 512].join("\n"); + let source = write_global(home.as_ref(), &oversized_global)?; + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(|config| config.project_doc_max_bytes = 1); + let test = builder.build(&server).await?; + + // Submit a turn so the complete global instruction item is rendered into model input. + test.submit_turn("inspect current global item limit behavior") + .await?; + + // Characterize the current gap: the project-doc budget does not cap the global item. + assert_eq!( + test.codex.instruction_sources().await, + vec![source], + "the oversized global file should still be selected as the sole source" + ); + let expected_fragment = expected_instruction_fragment(&test.config.cwd, &oversized_global); + assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); + assert!( + expected_fragment.len() > test.config.project_doc_max_bytes, + "characterization requires a global item larger than the configured project-doc budget" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> { // Set up a malformed global instruction file and one model response. @@ -430,9 +555,23 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() -> Result<()> { skip_if_no_network!(Ok(())); + run_subagent_global_instruction_case(/*fork_context*/ true).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fresh_subagent_uses_creation_time_instructions_without_parent_history() -> Result<()> { + skip_if_no_network!(Ok(())); + run_subagent_global_instruction_case(/*fork_context*/ false).await +} +async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> { // Set up matched responses for the parent seed, spawn call, child turn, and parent follow-up. let server = responses::start_mock_server().await; + let parent_prompt = if fork_context { + SPAWN_PARENT_PROMPT + } else { + SPAWN_FRESH_PARENT_PROMPT + }; let seed_mock = responses::mount_sse_once_match( &server, |request: &wiremock::Request| request_body_contains(request, SPAWN_SEED_PROMPT), @@ -445,11 +584,11 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() .await; let spawn_args = serde_json::to_string(&json!({ "message": SPAWN_CHILD_PROMPT, - "fork_context": true, + "fork_context": fork_context, }))?; let spawn_mock = responses::mount_sse_once_match( &server, - |request: &wiremock::Request| request_body_contains(request, SPAWN_PARENT_PROMPT), + move |request: &wiremock::Request| request_body_contains(request, parent_prompt), responses::sse(vec![ responses::ev_response_created("spawn-response"), responses::ev_function_call_with_namespace( @@ -510,24 +649,29 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; assert_ne!(source, new_source); let mut created_threads = test.thread_manager.subscribe_thread_created(); - test.submit_turn(SPAWN_PARENT_PROMPT).await?; + test.submit_turn(parent_prompt).await?; let child_thread_id = tokio::time::timeout(Duration::from_secs(10), created_threads.recv()) .await - .map_err(|_| anyhow!("timed out waiting for the forked subagent thread"))??; + .map_err(|_| anyhow!("timed out waiting for the subagent thread"))??; let child_thread = test.thread_manager.get_thread(child_thread_id).await?; let spawn_request = spawn_mock.single_request(); let child_request = tokio::time::timeout(Duration::from_secs(10), async { loop { - if let Some(request) = child_mock.requests().into_iter().next() { + if let Some(request) = child_mock.requests().into_iter().find(|request| { + request + .message_input_texts("user") + .iter() + .any(|text| text == SPAWN_CHILD_PROMPT) + }) { break request; } tokio::time::sleep(Duration::from_millis(10)).await; } }) .await - .map_err(|_| anyhow!("timed out waiting for the forked subagent request"))?; + .map_err(|_| anyhow!("timed out waiting for the subagent request"))?; - // Assert parent and child report and render the inherited creation-time snapshot exactly once. + // Assert parent and child report and render the parent's creation-time snapshot exactly once. let expected_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); assert_single_instruction_fragment(&seed_request, &expected_fragment); @@ -541,15 +685,35 @@ async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() assert_eq!( child_thread.instruction_sources().await, vec![source], - "forked subagent reports the inherited creation-time source" - ); - let seed_input = seed_request.input(); - let child_input = child_request.input(); - assert_eq!( - child_input.get(..seed_input.len()), - Some(seed_input.as_slice()), - "forked subagent should replay the parent's original structured input prefix" + "subagent reports the parent's creation-time source" ); + if fork_context { + let seed_input = seed_request.input(); + let child_input = child_request.input(); + assert_eq!( + child_input.get(..seed_input.len()), + Some(seed_input.as_slice()), + "forked subagent should replay the parent's original structured input prefix" + ); + } else { + let child_user_texts = child_request.message_input_texts("user"); + assert_eq!( + child_user_texts + .iter() + .filter(|text| text.as_str() == SPAWN_SEED_PROMPT) + .count(), + 0, + "fresh-context subagent should omit parent user history; observed: {child_user_texts:?}" + ); + assert_eq!( + child_user_texts + .iter() + .filter(|text| text.as_str() == SPAWN_CHILD_PROMPT) + .count(), + 1, + "fresh-context subagent should contain its own prompt exactly once; observed: {child_user_texts:?}" + ); + } Ok(()) } @@ -596,10 +760,10 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu "thread reports the creation-time global source before compaction" ); - // Materialize the old snapshot, add a preferred override, and manually compact the thread. + // Materialize the old snapshot, rewrite the selected file in place, and manually compact. test.submit_turn("first turn").await?; - let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_ne!(source, new_source); + let rewritten_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_eq!(source, rewritten_source); test.codex.submit(Op::Compact).await?; wait_for_event(&test.codex, |event| { @@ -608,7 +772,8 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu .await; test.submit_turn("after compact").await?; - // Assert every phase keeps the old rendering and the thread keeps the old reported source. + // Assert ordinary and compact turns keep the old rendering even though the reported source + // path now contains new text. let requests = response_mock.requests(); assert_eq!(requests.len(), 3); let expected_fragment = @@ -690,11 +855,13 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re Ok(()) } -// TODO(anp): Align legacy-resume instruction sources with the historical instructions replayed -// before compaction so the reported source list and model-visible context describe the same files. +// TODO(anp): Preserve the persisted model-visible instruction item across later full-context +// rebuilds. Reloading file contents into historical context rewrites model-visible history and +// invalidates the cached prefix; future behavior should keep the original item stable. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> Result<()> { - // Set up initial, resumed, manual-compaction, and rebuilt-context model responses. +async fn cold_resume_then_full_context_rebuild_uses_current_instructions() -> Result<()> { + // Set up an initial turn, a cold-resumed turn, manual compaction, and the later full-context + // rebuild. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( &server, @@ -723,7 +890,7 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> let home = Arc::new(TempDir::new()?); let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - // Create the initial thread and persist the old instruction snapshot to its rollout. + // Create the initial thread and persist its creation-time instruction snapshot. let mut initial_builder = test_codex().with_home(Arc::clone(&home)).with_config({ let provider = provider.clone(); move |config| config.model_provider = provider @@ -736,7 +903,7 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> vec![source.clone()], "initial thread reports the creation-time global source" ); - initial.submit_turn("persist legacy history").await?; + initial.submit_turn("persist resume history").await?; let rollout_path = initial .session_configured .rollout_path @@ -748,28 +915,43 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> }) .await; - // Add a preferred override, then cold-resume with the new source in current configuration. - let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_ne!(source, new_source); + // Rewrite the selected AGENTS.md in place, then cold-resume with freshly loaded configuration. + let rewritten_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_eq!(source, rewritten_source); let mut resume_builder = test_codex() .with_home(Arc::clone(&home)) .with_config(move |config| config.model_provider = provider); let resumed = resume_builder .resume(&server, Arc::clone(&home), rollout_path) .await?; - // Assert resume reports the new source while the first resumed turn replays old history. + + // Assert the same source path now resolves new file contents while cold resume replays the + // exact old historical prefix. assert_eq!( resumed.codex.instruction_sources().await, - vec![new_source.clone()], - "resumed thread reports the current global source" + vec![source.clone()], + "resumed thread reports the same file path after in-place mutation" + ); + assert_eq!( + fs::read_to_string(source.as_path())?, + NEW_GLOBAL_INSTRUCTIONS, + "the reported source path should contain the rewritten text" ); - resumed.submit_turn("resume legacy history").await?; + resumed.submit_turn("resume historical context").await?; let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); let old_fragment = expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); assert_single_instruction_fragment(&requests[0], &old_fragment); assert_single_instruction_fragment(&requests[1], &old_fragment); + let initial_input = requests[0].input(); + let resumed_input = requests[1].input(); + assert_eq!( + resumed_input.get(..initial_input.len()), + Some(initial_input.as_slice()), + "cold resume should replay the original structured input prefix" + ); - // Compact the resumed thread so legacy reconstruction can inject the current configuration. + // Compact the resumed thread, then issue a turn that rebuilds full context. resumed.codex.submit(Op::Compact).await?; wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -777,7 +959,8 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> .await; resumed.submit_turn("rebuild full context").await?; - // Assert compaction sees old history, then the rebuilt context uses the new source contents. + // Characterize the current cache-breaking behavior: compaction sees old history, but the + // following full-context rebuild injects the newly loaded same-path contents. let requests = response_mock.requests(); assert_eq!(requests.len(), 4); let new_fragment = expected_instruction_fragment(&resumed.config.cwd, NEW_GLOBAL_INSTRUCTIONS); @@ -785,8 +968,216 @@ async fn legacy_resume_rebuilds_from_current_config_after_manual_compaction() -> assert_single_instruction_fragment(&requests[3], &new_fragment); assert_eq!( resumed.codex.instruction_sources().await, - vec![new_source], - "resumed thread retains the current global source after compaction" + vec![source], + "resumed thread retains the same current source path after compaction" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn legacy_compaction_without_replacement_history_rebuilds_current_instructions_on_resume() +-> Result<()> { + // Create a current-format compacted rollout that can be rewritten to the legacy shape. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("legacy-initial-response"), + responses::ev_completed("legacy-initial-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("legacy-compact-response"), + responses::ev_assistant_message("legacy-compact-message", "legacy summary"), + responses::ev_completed("legacy-compact-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("legacy-resumed-response"), + responses::ev_completed("legacy-resumed-response"), + ]), + ], + ) + .await; + let provider = local_compaction_provider(&server); + let home = Arc::new(TempDir::new()?); + let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; + let mut initial_builder = test_codex().with_home(Arc::clone(&home)).with_config({ + let provider = provider.clone(); + move |config| config.model_provider = provider + }); + let initial = initial_builder.build(&server).await?; + + // Persist one compaction, shut down, and remove its replacement history to emulate an older + // rollout whose compacted item contains only the summary message. + initial.submit_turn("persist legacy-shaped history").await?; + initial.codex.submit(Op::Compact).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + rewrite_compaction_as_legacy(&rollout_path)?; + + // Rewrite the selected file in place and cold-resume with current configuration. + let rewritten_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_eq!(source, rewritten_source); + let mut resume_builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(move |config| config.model_provider = provider); + let resumed = resume_builder + .resume(&server, Arc::clone(&home), rollout_path) + .await?; + resumed.submit_turn("resume legacy compaction").await?; + + // Legacy reconstruction has no complete historical checkpoint, so it injects the newly loaded + // same-path instructions rather than replaying the old item. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let old_fragment = expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + let new_fragment = expected_instruction_fragment(&resumed.config.cwd, NEW_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &old_fragment); + assert_single_instruction_fragment(&requests[1], &old_fragment); + assert_single_instruction_fragment(&requests[2], &new_fragment); + assert_eq!( + resumed.codex.instruction_sources().await, + vec![source], + "legacy resume reports the rewritten same-path source" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_mutation() +-> Result<()> { + skip_if_no_network!(Ok(())); + + // Set up an ordinary turn, a remote-v2 compact response, and a post-compaction turn. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("remote-v2-initial-response"), + responses::ev_completed("remote-v2-initial-response"), + ]), + remote_v2_compaction_response(), + responses::sse(vec![ + responses::ev_response_created("remote-v2-follow-up-response"), + responses::ev_completed("remote-v2-follow-up-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("remote-v2-resumed-response"), + responses::ev_completed("remote-v2-resumed-response"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + let _ = config.features.enable(Feature::RemoteCompactionV2); + }); + let test = builder.build(&server).await?; + + // Materialize the old snapshot, rewrite the selected file in place, and compact remotely. + test.submit_turn("before remote v2 compaction").await?; + let rewritten_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; + assert_eq!(source, rewritten_source); + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("after remote v2 compaction").await?; + test.codex.flush_rollout().await?; + + // Assert the compact request, installed replacement history, and follow-up all keep the + // creation-time item despite the file-backed source now containing new text. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let old_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &old_fragment); + assert_single_instruction_fragment(&requests[1], &old_fragment); + assert_single_instruction_fragment(&requests[2], &old_fragment); + assert_eq!( + requests[1].input().last(), + Some(&json!({"type": "compaction_trigger"})), + "remote-v2 compact request should append exactly one compaction trigger" + ); + let rollout_path = test.codex.rollout_path().expect("rollout path"); + let replacement_history = replacement_history_from_rollout(&rollout_path)?; + assert_eq!( + instruction_fragments_in_items(&replacement_history), + Vec::::new(), + "remote-v2 replacement history currently omits the global-instruction fragment" + ); + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "running thread retains the selected same-path source" + ); + assert_eq!( + fs::read_to_string(source.as_path())?, + NEW_GLOBAL_INSTRUCTIONS, + "the selected source path should contain the rewritten text" + ); + + // Cold-resume the persisted replacement history with freshly loaded same-path configuration. + test.codex.submit(Op::Shutdown).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + let resumed_cwd = test.config.cwd.clone(); + let mut resume_builder = test_codex() + .with_home(Arc::clone(&home)) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config.cwd = resumed_cwd; + let _ = config.features.enable(Feature::RemoteCompactionV2); + }); + let resumed = resume_builder + .resume(&server, Arc::clone(&home), rollout_path) + .await?; + resumed + .submit_turn("after remote v2 compaction cold resume") + .await?; + + // Modern replacement-history resume replays the persisted checkpoint and its later old-context + // suffix even though the same source path now contains new text. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 4); + assert_single_instruction_fragment(&requests[3], &old_fragment); + let resumed_input = requests[3].input(); + assert_eq!( + resumed_input.get(..replacement_history.len()), + Some(replacement_history.as_slice()), + "remote-v2 cold resume should replay persisted replacement history verbatim" + ); + let post_compact_input = requests[2].input(); + assert_eq!( + resumed_input.get(..post_compact_input.len()), + Some(post_compact_input.as_slice()), + "remote-v2 cold resume should replay the complete post-compaction structured prefix" + ); + assert_eq!( + resumed.codex.instruction_sources().await, + vec![source], + "cold-resumed thread reports the same rewritten source path" ); Ok(()) From 51dd8b35382f6ca994a4bf2e0dbc3ee86f38e413 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sun, 7 Jun 2026 16:47:49 -0700 Subject: [PATCH 10/14] codex: address PR review feedback (#26830) --- codex-rs/core/tests/suite/global_instructions.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/tests/suite/global_instructions.rs b/codex-rs/core/tests/suite/global_instructions.rs index 636a79bd1150..0c95abfae943 100644 --- a/codex-rs/core/tests/suite/global_instructions.rs +++ b/codex-rs/core/tests/suite/global_instructions.rs @@ -284,6 +284,8 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re Ok(()) } +// TODO(anp): Enforce an independent hard limit for the global instruction context item, then +// update this characterization to assert that oversized global instructions are bounded. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn global_instruction_context_item_is_currently_not_limited_by_project_doc_budget() -> Result<()> { From 42f3de1f1526af2e9f14538a4e63f01789141166 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Mon, 8 Jun 2026 11:23:49 -0700 Subject: [PATCH 11/14] codex: clarify global instruction follow-up --- codex-rs/core/tests/suite/global_instructions.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/tests/suite/global_instructions.rs b/codex-rs/core/tests/suite/global_instructions.rs index 0c95abfae943..54ea7536090f 100644 --- a/codex-rs/core/tests/suite/global_instructions.rs +++ b/codex-rs/core/tests/suite/global_instructions.rs @@ -857,9 +857,10 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re Ok(()) } -// TODO(anp): Preserve the persisted model-visible instruction item across later full-context -// rebuilds. Reloading file contents into historical context rewrites model-visible history and -// invalidates the cached prefix; future behavior should keep the original item stable. +// Follow-up: confirm the desired behavior for persisted model-visible instruction items across +// later full-context rebuilds. Reloading file contents into historical context currently rewrites +// model-visible history and invalidates the cached prefix; decide whether the original item should +// remain stable instead. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cold_resume_then_full_context_rebuild_uses_current_instructions() -> Result<()> { // Set up an initial turn, a cold-resumed turn, manual compaction, and the later full-context From d2f5bddc9c6f8b8646af0a906e09115b78f28b66 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 9 Jun 2026 17:24:23 +0000 Subject: [PATCH 12/14] test: address user instruction review feedback --- .../app-server/tests/suite/v2/thread_fork.rs | 142 ------- .../tests/suite/v2/thread_resume.rs | 142 ------- .../core/tests/suite/compact_remote_parity.rs | 365 +----------------- codex-rs/core/tests/suite/mod.rs | 2 +- .../core/tests/suite/prompt_debug_tests.rs | 36 +- ...l_instructions.rs => user_instructions.rs} | 167 -------- codex-rs/mcp-server/tests/suite/codex_tool.rs | 23 +- 7 files changed, 34 insertions(+), 843 deletions(-) rename codex-rs/core/tests/suite/{global_instructions.rs => user_instructions.rs} (84%) diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index ad6665eaa2f2..a335b441ffcc 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -36,8 +36,6 @@ use codex_protocol::protocol::RolloutItem; use codex_rollout::append_rollout_item_to_path; use codex_rollout::append_thread_name; use codex_rollout::read_session_meta_line; -use codex_utils_absolute_path::AbsolutePathBuf; -use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -251,146 +249,6 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Ok(()) } -#[tokio::test] -async fn thread_fork_reloads_instruction_sources() -> Result<()> { - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("parent-response"), - responses::ev_completed("parent-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("fork-response"), - responses::ev_completed("fork-response"), - ]), - ], - ) - .await; - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - let global_agents = codex_home.path().join("AGENTS.md"); - std::fs::write(&global_agents, "global instructions")?; - let workspace = TempDir::new()?; - let project_agents = workspace.path().join("AGENTS.md"); - std::fs::write(&project_agents, "project instructions")?; - - let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let start_id = mcp - .send_thread_start_request(ThreadStartParams { - cwd: Some(workspace.path().display().to_string()), - ..Default::default() - }) - .await?; - let start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(start_id)), - ) - .await??; - let ThreadStartResponse { - thread, - instruction_sources, - .. - } = to_response::(start_resp)?; - let expected_sources = vec![ - AbsolutePathBuf::try_from(std::fs::canonicalize(&global_agents)?)?, - AbsolutePathBuf::try_from(project_agents.clone())?, - ]; - assert_eq!(instruction_sources, expected_sources); - - let turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![UserInput::Text { - text: "persist instruction snapshot".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), - ) - .await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - std::fs::remove_file(global_agents)?; - std::fs::remove_file(project_agents)?; - - let fork_id = mcp - .send_thread_fork_request(ThreadForkParams { - thread_id: thread.id, - ..Default::default() - }) - .await?; - let fork_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), - ) - .await??; - let ThreadForkResponse { - thread: forked_thread, - instruction_sources, - .. - } = to_response::(fork_resp)?; - - assert_eq!( - instruction_sources, - Vec::::new(), - "fork reloads sources after the files have been removed" - ); - - let fork_turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: forked_thread.id, - input: vec![UserInput::Text { - text: "inspect inherited instructions".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(fork_turn_id)), - ) - .await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - let expected_instruction = format!( - "# AGENTS.md instructions for {}\n\n\nglobal instructions\n\n--- project-doc ---\n\nproject instructions\n", - workspace.path().display() - ); - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2); - for request in requests { - let instruction_fragments = request - .message_input_texts("user") - .into_iter() - .filter(|text| text.starts_with("# AGENTS.md instructions for ")) - .collect::>(); - assert_eq!( - instruction_fragments, - vec![expected_instruction.clone()], - "parent and fork requests should contain the same historical instruction fragment" - ); - } - - Ok(()) -} - #[tokio::test] async fn thread_fork_inherits_explicit_source_name_from_session_index() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 36277c6a64a8..582177ee7da7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -332,148 +332,6 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul Ok(()) } -#[tokio::test] -async fn thread_resume_cold_thread_reloads_instruction_sources() -> Result<()> { - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("initial-response"), - responses::ev_completed("initial-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("resumed-response"), - responses::ev_completed("resumed-response"), - ]), - ], - ) - .await; - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - let old_global_agents = codex_home.path().join("AGENTS.md"); - std::fs::write(&old_global_agents, "old global instructions")?; - let workspace = TempDir::new()?; - - let mut first_mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; - - let start_id = first_mcp - .send_thread_start_request(ThreadStartParams { - cwd: Some(workspace.path().display().to_string()), - ..Default::default() - }) - .await?; - let start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - first_mcp.read_stream_until_response_message(RequestId::Integer(start_id)), - ) - .await??; - let ThreadStartResponse { - thread, - instruction_sources, - .. - } = to_response::(start_resp)?; - let old_global_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(old_global_agents)?)?; - assert_eq!(instruction_sources, vec![old_global_agents.clone()]); - - let turn_id = first_mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![UserInput::Text { - text: "materialize rollout".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - first_mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), - ) - .await??; - timeout( - DEFAULT_READ_TIMEOUT, - first_mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - drop(first_mcp); - - let new_global_agents = codex_home.path().join("AGENTS.override.md"); - std::fs::write(&new_global_agents, "new global instructions")?; - - let mut second_mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; - let resume_id = second_mcp - .send_thread_resume_request(ThreadResumeParams { - thread_id: thread.id, - ..Default::default() - }) - .await?; - let resume_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - second_mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), - ) - .await??; - let ThreadResumeResponse { - thread: resumed_thread, - instruction_sources, - .. - } = to_response::(resume_resp)?; - let new_global_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(new_global_agents)?)?; - assert_eq!(instruction_sources, vec![new_global_agents]); - - let resumed_turn_id = second_mcp - .send_turn_start_request(TurnStartParams { - thread_id: resumed_thread.id, - input: vec![UserInput::Text { - text: "inspect resumed instructions".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - second_mcp.read_stream_until_response_message(RequestId::Integer(resumed_turn_id)), - ) - .await??; - timeout( - DEFAULT_READ_TIMEOUT, - second_mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - let expected_instruction = format!( - "# AGENTS.md instructions for {}\n\n\nold global instructions\n", - workspace.path().display() - ); - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2); - let initial_instruction_fragments = requests[0] - .message_input_texts("user") - .into_iter() - .filter(|text| text.starts_with("# AGENTS.md instructions for ")) - .collect::>(); - assert_eq!( - initial_instruction_fragments, - vec![expected_instruction.clone()], - "initial model request should contain the creation-time instruction fragment" - ); - let resumed_instruction_fragments = requests[1] - .message_input_texts("user") - .into_iter() - .filter(|text| text.starts_with("# AGENTS.md instructions for ")) - .collect::>(); - assert_eq!( - resumed_instruction_fragments, - vec![expected_instruction], - "cold-resumed model request should replay the creation-time instruction fragment" - ); - - Ok(()) -} - #[tokio::test] async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index ccf275cee3b6..1b7c50936cb6 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -1,7 +1,6 @@ #![allow(clippy::expect_used)] use std::fs; -use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; @@ -19,7 +18,6 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses; use core_test_support::responses::ResponseMock; use core_test_support::skip_if_no_network; -use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -27,14 +25,10 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; -#[cfg(windows)] -const FIXED_CWD: &str = r"C:\tmp\codex_remote_compaction_parity_workspace"; -#[cfg(not(windows))] const FIXED_CWD: &str = "/tmp/codex_remote_compaction_parity_workspace"; const IMAGE_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; const SUMMARY: &str = "REMOTE_COMPACTION_PARITY_ENCRYPTED_SUMMARY"; const DUMMY_FUNCTION_NAME: &str = "test_tool"; -const USER_INSTRUCTIONS: &str = "PARITY_USER_INSTRUCTIONS"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Mode { @@ -110,13 +104,6 @@ struct Capture { compact_requests: usize, } -#[derive(Debug)] -struct PersistedResumeCapture { - compact_body: Value, - replacement_history: Value, - resumed_body: Value, -} - const ASSISTANT_ONLY: &[Step] = &[Step::Assistant]; const REASONING_IMAGE: &[Step] = &[Step::ReasoningAssistant, Step::ImageAssistant]; const TOOL_MIX: &[Step] = &[Step::Assistant, Step::FunctionTool, Step::ShellTool]; @@ -186,12 +173,7 @@ async fn remote_compaction_parity_v2_api_key_sends_service_tier_upgrade() -> Res ); assert_compact_requests_eq_except_v2_service_tier("api-key service tier", &legacy, &v2); - assert_follow_up_and_history_eq( - "api-key service tier", - &legacy, - &v2, - /*replacement_instruction_messages*/ 0, - ); + assert_follow_up_and_history_eq("api-key service tier", &legacy, &v2); Ok(()) } @@ -211,12 +193,7 @@ async fn remote_compaction_parity_pre_turn_auto() -> Result<()> { let legacy = run_pre_turn_auto_session(Mode::Legacy).await?; let v2 = run_pre_turn_auto_session(Mode::V2).await?; - assert_capture_eq( - "pre-turn auto", - &legacy, - &v2, - /*replacement_instruction_messages*/ 0, - ); + assert_capture_eq("pre-turn auto", &legacy, &v2); Ok(()) } @@ -226,75 +203,18 @@ async fn remote_compaction_parity_mid_turn_auto() -> Result<()> { let legacy = run_mid_turn_auto_session(Mode::Legacy).await?; let v2 = run_mid_turn_auto_session(Mode::V2).await?; - assert_capture_eq( - "mid-turn auto", - &legacy, - &v2, - /*replacement_instruction_messages*/ 1, - ); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_compaction_parity_persisted_history_cold_resume() -> Result<()> { - skip_if_no_network!(Ok(())); - - let legacy = run_persisted_history_resume(Mode::Legacy).await?; - let v2 = run_persisted_history_resume(Mode::V2).await?; - - assert_single_user_instruction_message( - &legacy.compact_body, - "legacy compact request before cold resume", - ); - assert_single_user_instruction_message( - &v2.compact_body, - "v2 compact request before cold resume", - ); - assert_user_instruction_messages_in_items( - &legacy.replacement_history, - "legacy persisted replacement history", - /*expected_count*/ 0, - ); - assert_user_instruction_messages_in_items( - &v2.replacement_history, - "v2 persisted replacement history", - /*expected_count*/ 0, - ); - assert_single_user_instruction_message(&legacy.resumed_body, "legacy cold-resumed request"); - assert_single_user_instruction_message(&v2.resumed_body, "v2 cold-resumed request"); - - assert_json_eq( - "cold-resume replacement history parity mismatch", - &legacy.replacement_history, - &v2.replacement_history, - ); - assert_json_eq( - "cold-resume request parity mismatch", - &follow_up_request_view(&legacy.resumed_body), - &follow_up_request_view(&v2.resumed_body), - ); - + assert_capture_eq("mid-turn auto", &legacy, &v2); Ok(()) } async fn compare_manual_scenario(scenario: &Scenario, settings: RunSettings) -> Result<()> { let legacy = run_manual_session(scenario, Mode::Legacy, settings).await?; let v2 = run_manual_session(scenario, Mode::V2, settings).await?; - assert_capture_eq( - scenario.name, - &legacy, - &v2, - /*replacement_instruction_messages*/ 0, - ); + assert_capture_eq(scenario.name, &legacy, &v2); Ok(()) } -fn assert_capture_eq( - label: &str, - legacy: &Capture, - v2: &Capture, - replacement_instruction_messages: usize, -) { +fn assert_capture_eq(label: &str, legacy: &Capture, v2: &Capture) { assert_eq!( legacy.compact_requests, 1, "legacy compact endpoint should be called exactly once for {label}", @@ -311,14 +231,6 @@ fn assert_capture_eq( &legacy_compact, &v2_compact, ); - assert_single_user_instruction_message( - &legacy.compact_body, - &format!("legacy compact request for {label}"), - ); - assert_single_user_instruction_message( - &v2.compact_body, - &format!("v2 compact request for {label}"), - ); let legacy_follow_up = follow_up_request_view(&legacy.follow_up_body); let v2_follow_up = follow_up_request_view(&v2.follow_up_body); @@ -327,30 +239,12 @@ fn assert_capture_eq( &legacy_follow_up, &v2_follow_up, ); - assert_single_user_instruction_message( - &legacy.follow_up_body, - &format!("legacy follow-up for {label}"), - ); - assert_single_user_instruction_message( - &v2.follow_up_body, - &format!("v2 follow-up for {label}"), - ); assert_json_eq( &format!("replacement history parity mismatch for {label}"), &legacy.replacement_history, &v2.replacement_history, ); - assert_user_instruction_messages_in_items( - &legacy.replacement_history, - &format!("legacy replacement history for {label}"), - replacement_instruction_messages, - ); - assert_user_instruction_messages_in_items( - &v2.replacement_history, - &format!("v2 replacement history for {label}"), - replacement_instruction_messages, - ); println!( "PARITY_OK scenario={} normal_response_requests={} compact_input_items={} replacement_history_items={} follow_up_input_items={}", @@ -380,22 +274,9 @@ fn assert_compact_requests_eq_except_v2_service_tier(label: &str, legacy: &Captu &legacy_compact, &v2_compact, ); - assert_single_user_instruction_message( - &legacy.compact_body, - &format!("legacy compact request for {label}"), - ); - assert_single_user_instruction_message( - &v2.compact_body, - &format!("v2 compact request for {label}"), - ); } -fn assert_follow_up_and_history_eq( - label: &str, - legacy: &Capture, - v2: &Capture, - replacement_instruction_messages: usize, -) { +fn assert_follow_up_and_history_eq(label: &str, legacy: &Capture, v2: &Capture) { let legacy_follow_up = follow_up_request_view(&legacy.follow_up_body); let v2_follow_up = follow_up_request_view(&v2.follow_up_body); assert_json_eq( @@ -403,30 +284,12 @@ fn assert_follow_up_and_history_eq( &legacy_follow_up, &v2_follow_up, ); - assert_single_user_instruction_message( - &legacy.follow_up_body, - &format!("legacy follow-up for {label}"), - ); - assert_single_user_instruction_message( - &v2.follow_up_body, - &format!("v2 follow-up for {label}"), - ); assert_json_eq( &format!("replacement history parity mismatch for {label}"), &legacy.replacement_history, &v2.replacement_history, ); - assert_user_instruction_messages_in_items( - &legacy.replacement_history, - &format!("legacy replacement history for {label}"), - replacement_instruction_messages, - ); - assert_user_instruction_messages_in_items( - &v2.replacement_history, - &format!("v2 replacement history for {label}"), - replacement_instruction_messages, - ); } async fn run_manual_session( @@ -582,92 +445,6 @@ async fn run_mid_turn_auto_session(mode: Mode) -> Result { .await } -async fn run_persisted_history_resume(mode: Mode) -> Result { - let response_bodies = match mode { - Mode::Legacy => vec![ - responses::sse(vec![ - responses::ev_assistant_message( - "persisted-history-first-message", - "PERSISTED_HISTORY_FIRST_REPLY", - ), - responses::ev_completed("persisted-history-first-response"), - ]), - after_compact_response_body("persisted_history_resume"), - ], - Mode::V2 => vec![ - responses::sse(vec![ - responses::ev_assistant_message( - "persisted-history-first-message", - "PERSISTED_HISTORY_FIRST_REPLY", - ), - responses::ev_completed("persisted-history-first-response"), - ]), - compaction_v2_response_body(), - after_compact_response_body("persisted_history_resume"), - ], - }; - let settings = RunSettings::default(); - let harness = build_harness(mode, settings, /*hooks*/ false).await?; - let rollout_path = rollout_path(&harness); - let home = harness.test().home.clone(); - let codex = harness.test().codex.clone(); - let responses_mock = responses::mount_sse_sequence(harness.server(), response_bodies).await; - let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; - - submit_user_input( - &codex, - vec![UserInput::Text { - text: "persisted history before compact".to_string(), - text_elements: Vec::new(), - }], - ) - .await?; - codex.submit(Op::Compact).await?; - wait_for_turn_complete(&codex).await; - codex.submit(Op::Shutdown).await?; - wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - - let response_requests = responses_mock.requests(); - let compact_body = match (mode, compact_mock.as_ref()) { - (Mode::Legacy, Some(compact_mock)) => compact_mock.single_request().body_json(), - (Mode::V2, None) => response_requests - .last() - .expect("v2 compact request should be present") - .body_json(), - (Mode::Legacy, None) | (Mode::V2, Some(_)) => panic!("unexpected compact mock state"), - }; - let replacement_history = replacement_history_from_rollout(&rollout_path)?; - - let mut resume_builder = parity_builder(mode, settings, /*hooks*/ false); - let resumed = resume_builder - .resume(harness.server(), home, rollout_path) - .await?; - submit_user_input( - &resumed.codex, - vec![UserInput::Text { - text: "persisted history after cold resume".to_string(), - text_elements: Vec::new(), - }], - ) - .await?; - let resumed_body = responses_mock - .requests() - .last() - .expect("cold-resumed request should be present") - .body_json(); - resumed.codex.submit(Op::Shutdown).await?; - wait_for_event(&resumed.codex, |ev| { - matches!(ev, EventMsg::ShutdownComplete) - }) - .await; - - Ok(PersistedResumeCapture { - compact_body, - replacement_history, - resumed_body, - }) -} - async fn run_manual_hook_session(mode: Mode) -> Result { let response_bodies = match mode { Mode::Legacy => vec![responses::sse(vec![ @@ -731,48 +508,32 @@ async fn build_harness_inner( hooks: bool, auto_compact_limit: Option, ) -> Result { - prepare_fixed_cwd()?; - TestCodexHarness::with_builder( - parity_builder(mode, settings, hooks) - .with_config(move |config| config.model_auto_compact_token_limit = auto_compact_limit), - ) - .await -} - -fn parity_builder(mode: Mode, settings: RunSettings, hooks: bool) -> TestCodexBuilder { + fs::create_dir_all(FIXED_CWD)?; let mut builder = test_codex().with_auth(settings.auth.build()); if hooks { builder = builder.with_pre_build_hook(write_manual_compact_hooks); } - builder.with_config(move |config| { + TestCodexHarness::with_builder(builder.with_config(move |config| { config.cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(PathBuf::from( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(USER_INSTRUCTIONS)); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "PARITY_USER_INSTRUCTIONS", + )); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); } + config.model_auto_compact_token_limit = auto_compact_limit; if hooks { trust_discovered_hooks(config); } if mode == Mode::V2 { let _ = config.features.enable(Feature::RemoteCompactionV2); } - }) -} - -fn prepare_fixed_cwd() -> Result<()> { - fs::create_dir_all(FIXED_CWD)?; - for filename in ["AGENTS.md", "AGENTS.override.md"] { - match fs::remove_file(Path::new(FIXED_CWD).join(filename)) { - Ok(()) => {} - Err(err) if err.kind() == ErrorKind::NotFound => {} - Err(err) => return Err(err.into()), - } - } - Ok(()) + })) + .await } fn rollout_path(harness: &TestCodexHarness) -> PathBuf { @@ -1015,104 +776,6 @@ fn follow_up_request_view(body: &Value) -> Value { canonical_json(&normalize_value(selected)) } -fn assert_single_user_instruction_message(body: &Value, label: &str) { - let input = body - .get("input") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - assert_user_instruction_messages_in_items( - &Value::Array(input), - label, - /*expected_count*/ 1, - ); -} - -fn assert_user_instruction_messages_in_items(items: &Value, label: &str, expected_count: usize) { - let expected_text = format!( - "# AGENTS.md instructions for {FIXED_CWD}\n\n\n{USER_INSTRUCTIONS}\n" - ); - let instruction_messages = items - .as_array() - .into_iter() - .flatten() - .filter(|item| { - item.get("type").and_then(Value::as_str) == Some("message") - && item.get("role").and_then(Value::as_str) == Some("user") - }) - .filter_map(|item| { - let content = item.get("content").and_then(Value::as_array)?; - content - .iter() - .any(|span| { - span.get("type").and_then(Value::as_str) == Some("input_text") - && span - .get("text") - .and_then(Value::as_str) - .is_some_and(|text| text.starts_with("# AGENTS.md instructions for ")) - }) - .then(|| { - json!({ - "role": item["role"].clone(), - "content": item["content"].clone(), - }) - }) - }) - .collect::>(); - if expected_count == 0 { - assert_eq!( - instruction_messages, - Vec::::new(), - "{label} should omit global-instruction messages" - ); - return; - } - assert_eq!( - expected_count, 1, - "instruction assertion helper only supports exact counts of zero or one" - ); - let expected_content = instruction_messages - .first() - .and_then(|message| message.get("content")) - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter(|span| { - span.get("type").and_then(Value::as_str) == Some("input_text") - && span - .get("text") - .and_then(Value::as_str) - .is_some_and(|text| { - text == expected_text - || (text.starts_with("") - && text.ends_with("")) - }) - }) - .cloned() - .collect::>(); - assert_eq!( - instruction_messages, - vec![json!({ - "role": "user", - "content": expected_content, - })], - "{label} should contain exactly one dedicated contextual user-instruction message" - ); - assert_eq!( - instruction_messages[0]["content"] - .as_array() - .into_iter() - .flatten() - .filter(|span| { - span.get("type").and_then(Value::as_str) == Some("input_text") - && span.get("text").and_then(Value::as_str) == Some(expected_text.as_str()) - }) - .count(), - 1, - "{label} should contain the exact instruction span once" - ); -} - fn replacement_history_from_rollout(path: &Path) -> Result { let rollout_text = fs::read_to_string(path)?; let mut replacement_history = None; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 19c744176786..83e0052a2e50 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -52,7 +52,6 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; -mod global_instructions; #[cfg(not(target_os = "windows"))] mod guardian_review; mod hierarchical_agents; @@ -120,6 +119,7 @@ mod unified_exec; #[cfg(unix)] mod unified_exec_zsh_fork_approvals; mod unstable_features_warning; +mod user_instructions; mod user_notification; mod user_shell_cmd; mod view_image; diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index b5d6020bf029..bad7e1ab5aa2 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_core::build_prompt_input; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -8,14 +9,11 @@ use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; use tempfile::TempDir; -const PROJECT_INSTRUCTIONS: &str = "Project-specific test instructions"; - #[tokio::test] async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - std::fs::write(codex_home.path().join("AGENTS.md"), PROJECT_INSTRUCTIONS)?; - let config = ConfigBuilder::default() + let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), @@ -24,6 +22,10 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "Project-specific test instructions", + )); + let input = build_prompt_input( config, vec![UserInput::Text { @@ -43,23 +45,19 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { phase: None, }; assert_eq!(input.last(), Some(&expected_user_message)); - assert!( - input.iter().any(|item| { - let ResponseItem::Message { content, .. } = item else { + assert!(input.iter().any(|item| { + let ResponseItem::Message { content, .. } = item else { + return false; + }; + + content.iter().any(|content_item| { + let (ContentItem::InputText { text } | ContentItem::OutputText { text }) = content_item + else { return false; }; - - content.iter().any(|content_item| { - let (ContentItem::InputText { text } | ContentItem::OutputText { text }) = - content_item - else { - return false; - }; - text.contains(PROJECT_INSTRUCTIONS) - }) - }), - "expected prompt input to contain {PROJECT_INSTRUCTIONS:?}; observed: {input:#?}" - ); + text.contains("Project-specific test instructions") + }) + })); Ok(()) } diff --git a/codex-rs/core/tests/suite/global_instructions.rs b/codex-rs/core/tests/suite/user_instructions.rs similarity index 84% rename from codex-rs/core/tests/suite/global_instructions.rs rename to codex-rs/core/tests/suite/user_instructions.rs index 54ea7536090f..55d362fbe8ff 100644 --- a/codex-rs/core/tests/suite/global_instructions.rs +++ b/codex-rs/core/tests/suite/user_instructions.rs @@ -284,49 +284,6 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re Ok(()) } -// TODO(anp): Enforce an independent hard limit for the global instruction context item, then -// update this characterization to assert that oversized global instructions are bounded. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn global_instruction_context_item_is_currently_not_limited_by_project_doc_budget() --> Result<()> { - // Set a one-byte project-doc budget, then create a much larger global instruction file. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_once( - &server, - responses::sse(vec![ - responses::ev_response_created("oversized-global-response"), - responses::ev_completed("oversized-global-response"), - ]), - ) - .await; - let home = Arc::new(TempDir::new()?); - let oversized_global = vec!["global instruction item remains uncapped"; 512].join("\n"); - let source = write_global(home.as_ref(), &oversized_global)?; - let mut builder = test_codex() - .with_home(Arc::clone(&home)) - .with_config(|config| config.project_doc_max_bytes = 1); - let test = builder.build(&server).await?; - - // Submit a turn so the complete global instruction item is rendered into model input. - test.submit_turn("inspect current global item limit behavior") - .await?; - - // Characterize the current gap: the project-doc budget does not cap the global item. - assert_eq!( - test.codex.instruction_sources().await, - vec![source], - "the oversized global file should still be selected as the sole source" - ); - let expected_fragment = expected_instruction_fragment(&test.config.cwd, &oversized_global); - assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); - assert!( - expected_fragment.len() > test.config.project_doc_max_bytes, - "characterization requires a global item larger than the configured project-doc budget" - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> { // Set up a malformed global instruction file and one model response. @@ -452,8 +409,6 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so Ok(()) } -// TODO(anp): Align fork instruction sources with the historical instructions replayed to the -// model so the reported source list and model-visible context describe the same files. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> { // Set up a parent turn and a later fork turn against the parent's rollout. @@ -524,7 +479,6 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> text: "continue fork".to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -857,127 +811,6 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re Ok(()) } -// Follow-up: confirm the desired behavior for persisted model-visible instruction items across -// later full-context rebuilds. Reloading file contents into historical context currently rewrites -// model-visible history and invalidates the cached prefix; decide whether the original item should -// remain stable instead. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cold_resume_then_full_context_rebuild_uses_current_instructions() -> Result<()> { - // Set up an initial turn, a cold-resumed turn, manual compaction, and the later full-context - // rebuild. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("initial-response"), - responses::ev_completed("initial-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("resumed-response"), - responses::ev_completed("resumed-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("compact-response"), - responses::ev_assistant_message("compact-message", "summary"), - responses::ev_completed("compact-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("post-compact-response"), - responses::ev_completed("post-compact-response"), - ]), - ], - ) - .await; - let provider = local_compaction_provider(&server); - let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - - // Create the initial thread and persist its creation-time instruction snapshot. - let mut initial_builder = test_codex().with_home(Arc::clone(&home)).with_config({ - let provider = provider.clone(); - move |config| config.model_provider = provider - }); - let initial = initial_builder.build(&server).await?; - - // Assert the initial thread reports the source used for its historical snapshot. - assert_eq!( - initial.codex.instruction_sources().await, - vec![source.clone()], - "initial thread reports the creation-time global source" - ); - initial.submit_turn("persist resume history").await?; - let rollout_path = initial - .session_configured - .rollout_path - .clone() - .expect("rollout path"); - initial.codex.submit(Op::Shutdown).await?; - wait_for_event(&initial.codex, |event| { - matches!(event, EventMsg::ShutdownComplete) - }) - .await; - - // Rewrite the selected AGENTS.md in place, then cold-resume with freshly loaded configuration. - let rewritten_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_eq!(source, rewritten_source); - let mut resume_builder = test_codex() - .with_home(Arc::clone(&home)) - .with_config(move |config| config.model_provider = provider); - let resumed = resume_builder - .resume(&server, Arc::clone(&home), rollout_path) - .await?; - - // Assert the same source path now resolves new file contents while cold resume replays the - // exact old historical prefix. - assert_eq!( - resumed.codex.instruction_sources().await, - vec![source.clone()], - "resumed thread reports the same file path after in-place mutation" - ); - assert_eq!( - fs::read_to_string(source.as_path())?, - NEW_GLOBAL_INSTRUCTIONS, - "the reported source path should contain the rewritten text" - ); - resumed.submit_turn("resume historical context").await?; - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2); - let old_fragment = expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&requests[0], &old_fragment); - assert_single_instruction_fragment(&requests[1], &old_fragment); - let initial_input = requests[0].input(); - let resumed_input = requests[1].input(); - assert_eq!( - resumed_input.get(..initial_input.len()), - Some(initial_input.as_slice()), - "cold resume should replay the original structured input prefix" - ); - - // Compact the resumed thread, then issue a turn that rebuilds full context. - resumed.codex.submit(Op::Compact).await?; - wait_for_event(&resumed.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - resumed.submit_turn("rebuild full context").await?; - - // Characterize the current cache-breaking behavior: compaction sees old history, but the - // following full-context rebuild injects the newly loaded same-path contents. - let requests = response_mock.requests(); - assert_eq!(requests.len(), 4); - let new_fragment = expected_instruction_fragment(&resumed.config.cwd, NEW_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&requests[2], &old_fragment); - assert_single_instruction_fragment(&requests[3], &new_fragment); - assert_eq!( - resumed.codex.instruction_sources().await, - vec![source], - "resumed thread retains the same current source path after compaction" - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn legacy_compaction_without_replacement_history_rebuilds_current_instructions_on_resume() -> Result<()> { diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index ca31a9bf2aca..d9f290c3c59f 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -371,10 +371,6 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { // Run `codex mcp` with a specific config.toml. let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - std::fs::write( - codex_home.path().join("AGENTS.md"), - "MCP global instructions", - )?; let mut mcp_process = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??; @@ -440,26 +436,11 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { developer_contents .iter() .any(|content| content.contains("`sandbox_mode`")), - "expected developer contents to contain text with \"`sandbox_mode`\"; observed: {developer_contents:?}" + "expected permissions developer message, got {developer_contents:?}" ); assert!( developer_contents.contains(&"Foreshadow upcoming tool calls."), - "expected developer contents to contain \"Foreshadow upcoming tool calls.\"; observed: {developer_contents:?}" - ); - let user_contents = request["input"] - .as_array() - .expect("responses request should include input items") - .iter() - .filter(|msg| msg.get("role").and_then(serde_json::Value::as_str) == Some("user")) - .filter_map(|msg| msg.get("content").and_then(serde_json::Value::as_array)) - .flatten() - .filter_map(|span| span.get("text").and_then(serde_json::Value::as_str)) - .collect::>(); - assert!( - user_contents - .iter() - .any(|content| content.contains("MCP global instructions")), - "expected user contents to contain text with \"MCP global instructions\"; observed: {user_contents:?}" + "expected developer instructions in developer messages, got {developer_contents:?}" ); Ok(()) From 5111a85c16c9ac17dc3adc5e7b0e1cadfafae93c Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 9 Jun 2026 19:15:37 +0000 Subject: [PATCH 13/14] tests(core): group instruction lifecycle coverage --- codex-rs/core/tests/suite/agents_md.rs | 603 +++++++++- codex-rs/core/tests/suite/compact.rs | 385 +++++++ codex-rs/core/tests/suite/mod.rs | 1 - .../core/tests/suite/user_instructions.rs | 1020 ----------------- 4 files changed, 987 insertions(+), 1022 deletions(-) delete mode 100644 codex-rs/core/tests/suite/user_instructions.rs diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 3fa2136ecbd9..44eb0d0f08ce 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,16 +1,30 @@ use anyhow::Result; +use anyhow::anyhow; +use codex_core::ForkSnapshot; use codex_exec_server::CreateDirectoryOptions; +use codex_features::Feature; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; use core_test_support::create_directory_symlink; +use core_test_support::load_default_config_for_test; +use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use serde_json::json; use std::sync::Arc; +use std::time::Duration; use tempfile::TempDir; async fn agents_instructions(mut builder: TestCodexBuilder) -> Result { @@ -250,7 +264,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu }); let test = builder.build_with_remote_env(&server).await?; let project_agents = test.config.cwd.join("AGENTS.md"); - let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); + let global_agents = global_agents.abs(); assert_eq!( test.codex.instruction_sources().await, @@ -268,3 +282,590 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok(()) } + +const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; +const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; +const GLOBAL_INSTRUCTIONS: &str = "global instructions"; +const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; +const NEW_PROJECT_INSTRUCTIONS: &str = "new project instructions"; +const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; +const PROJECT_INSTRUCTIONS: &str = "project instructions"; +const PROJECT_SEPARATOR: &str = "--- project-doc ---"; +const SPAWN_CALL_ID: &str = "spawn-global-instructions-child"; +const SPAWN_CHILD_PROMPT: &str = "inspect inherited global instructions"; +const SPAWN_FRESH_PARENT_PROMPT: &str = "spawn a child with fresh context"; +const SPAWN_PARENT_PROMPT: &str = "spawn a child with the parent context"; +const SPAWN_SEED_PROMPT: &str = "seed parent history"; + +fn write_global_file( + home: &TempDir, + filename: &str, + contents: impl AsRef<[u8]>, +) -> Result { + let path = home.path().join(filename); + std::fs::write(&path, contents)?; + Ok(path.abs()) +} + +fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { + request + .message_input_texts("user") + .into_iter() + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .collect() +} + +fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { + let cwd = cwd.as_path().display(); + format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") +} + +fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) { + assert_eq!(instruction_fragments(request), vec![expected.to_string()]); +} + +fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { + let is_zstd = request + .headers + .get("content-encoding") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) + }); + let body = if is_zstd { + zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok() + } else { + Some(request.body.clone()) + }; + body.and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { + // Set up one global source, one project source, and two ordinary model turns. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("response-1"), + responses::ev_completed("response-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("response-2"), + responses::ev_completed("response-2"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let global_source = + write_global_file(home.as_ref(), GLOBAL_AGENTS_FILENAME, GLOBAL_INSTRUCTIONS)?; + + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &cwd.join("AGENTS.md"), + PROJECT_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + Ok(()) + }); + let test = builder.build_with_remote_env(&server).await?; + let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME); + let creation_sources = vec![global_source.clone(), project_source.clone()]; + + // Confirm the thread records both creation-time sources in composition order. + assert_eq!(test.codex.instruction_sources().await, creation_sources); + + // Materialize the initial snapshot, then rewrite both selected files in place before another + // ordinary turn. + test.submit_turn("first turn").await?; + let rewritten_global_source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + NEW_GLOBAL_INSTRUCTIONS, + )?; + test.fs() + .write_file( + &project_source, + NEW_PROJECT_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + assert_eq!( + rewritten_global_source, global_source, + "same-path mutation should retain the selected global source path" + ); + test.submit_turn("second turn").await?; + + // Assert the running thread keeps its original rendering and structured prefix even though + // both files at the reported source paths now contain different text. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let expected_contents = + format!("{GLOBAL_INSTRUCTIONS}\n\n{PROJECT_SEPARATOR}\n\n{PROJECT_INSTRUCTIONS}"); + let expected_fragment = expected_instruction_fragment(&test.config.cwd, &expected_contents); + let fragments = instruction_fragments(&requests[0]); + assert_eq!(fragments, vec![expected_fragment.clone()]); + assert_single_instruction_fragment(&requests[1], &expected_fragment); + let rendered = fragments + .into_iter() + .next() + .ok_or_else(|| anyhow!("expected one rendered instruction fragment"))?; + let global_position = rendered.find(GLOBAL_INSTRUCTIONS).ok_or_else(|| { + anyhow!( + "expected rendered instructions to contain {GLOBAL_INSTRUCTIONS:?}; observed: {rendered}" + ) + })?; + let project_position = rendered.find(PROJECT_INSTRUCTIONS).ok_or_else(|| { + anyhow!( + "expected rendered instructions to contain {PROJECT_INSTRUCTIONS:?}; observed: {rendered}" + ) + })?; + assert!( + global_position < project_position, + "global instructions should precede project instructions: {rendered}" + ); + assert!( + rendered.contains(PROJECT_SEPARATOR), + "expected rendered instructions to contain {PROJECT_SEPARATOR:?}; observed: {rendered}" + ); + assert_eq!( + test.codex.instruction_sources().await, + creation_sources, + "ordinary turns retain the creation-time source list" + ); + let first_input = requests[0].input(); + let second_input = requests[1].input(); + assert_eq!( + second_input.get(..first_input.len()), + Some(first_input.as_slice()), + "the ordinary second turn should retain the cached prefix" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> { + // Set up a malformed global instruction file and one model response. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("warning-response"), + responses::ev_completed("warning-response"), + ]), + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + b"global\xFFinstructions", + )?; + + // Create the thread, capture its load warning, and submit one turn for rendered output. + let mut builder = test_codex().with_home(home); + let test = builder.build(&server).await?; + let warning = wait_for_event_match(&test.codex, |event| match event { + EventMsg::Warning(warning) + if warning + .message + .contains(source.as_path().display().to_string().as_str()) => + { + Some(warning.message.clone()) + } + _ => None, + }) + .await; + test.submit_turn("inspect lossy global instructions") + .await?; + + // Assert the source is reported, the warning is specific, and rendering is lossily decoded. + assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]); + assert!( + warning.contains("invalid UTF-8"), + "expected warning to contain \"invalid UTF-8\"; observed: {warning}" + ); + let expected_fragment = + expected_instruction_fragment(&test.config.cwd, "global\u{FFFD}instructions"); + assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); + + Ok(()) +} + +// TODO(anp): Align cold-resume instruction sources with the historical instructions replayed to +// the model so the API source list and model-visible context describe the same files. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cold_resume_replays_rendered_instructions_but_reports_current_config_sources() -> Result<()> +{ + // Set up an initial turn and a later cold-resumed turn against the same rollout. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("initial-response"), + responses::ev_completed("initial-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("resumed-response"), + responses::ev_completed("resumed-response"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let old_source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + OLD_GLOBAL_INSTRUCTIONS, + )?; + + // Create the initial thread and persist its creation-time instruction snapshot. + let mut initial_builder = test_codex().with_home(Arc::clone(&home)); + let initial = initial_builder.build(&server).await?; + + // Assert the pre-resume thread reports the source used to create its snapshot. + assert_eq!( + initial.codex.instruction_sources().await, + vec![old_source.clone()], + "initial thread reports the creation-time global source" + ); + initial.submit_turn("persist instructions").await?; + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + // Add a preferred override source, then cold-resume with freshly loaded configuration. + let new_source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_OVERRIDE_FILENAME, + NEW_GLOBAL_INSTRUCTIONS, + )?; + assert_ne!(old_source, new_source); + let mut resume_builder = test_codex().with_home(Arc::clone(&home)); + let resumed = resume_builder + .resume(&server, Arc::clone(&home), rollout_path) + .await?; + + // Assert the API reports the new source while model history replays the old structured prefix. + assert_eq!( + resumed.codex.instruction_sources().await, + vec![new_source], + "resume reports sources from the newly loaded config" + ); + + resumed.submit_turn("continue resumed thread").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let initial_input = requests[0].input(); + let resumed_input = requests[1].input(); + assert_eq!( + resumed_input.get(..initial_input.len()), + Some(initial_input.as_slice()), + "cold resume should replay the original structured input prefix" + ); + let expected_fragment = + expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &expected_fragment); + assert_single_instruction_fragment(&requests[1], &expected_fragment); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> { + // Set up a parent turn and a later fork turn against the parent's rollout. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("parent-response"), + responses::ev_completed("parent-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("fork-response"), + responses::ev_completed("fork-response"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + OLD_GLOBAL_INSTRUCTIONS, + )?; + + // Create the parent and persist its creation-time instruction snapshot. + let mut builder = test_codex().with_home(Arc::clone(&home)); + let parent = builder.build(&server).await?; + + // Assert the parent reports the source used to create its snapshot. + assert_eq!( + parent.codex.instruction_sources().await, + vec![source.clone()], + "parent reports the creation-time global source" + ); + parent.submit_turn("persist instructions").await?; + parent.codex.ensure_rollout_materialized().await; + parent.codex.flush_rollout().await?; + let rollout_path = parent.codex.rollout_path().expect("rollout path"); + + // Add a preferred override source, then fork with freshly loaded configuration. + let new_source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_OVERRIDE_FILENAME, + NEW_GLOBAL_INSTRUCTIONS, + )?; + assert_ne!(source, new_source); + let mut fork_config = load_default_config_for_test(home.as_ref()).await; + fork_config.cwd = parent.config.cwd.clone(); + fork_config.model = parent.config.model.clone(); + fork_config.model_provider = parent.config.model_provider.clone(); + fork_config.model_catalog = parent.config.model_catalog.clone(); + fork_config.codex_self_exe = parent.config.codex_self_exe.clone(); + let forked = parent + .thread_manager + .fork_thread( + ForkSnapshot::Interrupted, + fork_config, + rollout_path, + /*thread_source*/ None, + /*parent_trace*/ None, + ) + .await?; + + // Assert the fork reports the new source before issuing its first turn. + assert_eq!( + forked.thread.instruction_sources().await, + vec![new_source], + "fork config should reflect the newly loaded global source" + ); + + forked + .thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "continue fork".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&forked.thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + // Assert the forked model request replays the parent's exact structured history. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let parent_input = requests[0].input(); + let fork_input = requests[1].input(); + assert_eq!( + fork_input.get(..parent_input.len()), + Some(parent_input.as_slice()), + "fork should replay the parent's original structured input prefix" + ); + let expected_fragment = + expected_instruction_fragment(&parent.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &expected_fragment); + assert_single_instruction_fragment(&requests[1], &expected_fragment); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() -> Result<()> { + skip_if_no_network!(Ok(())); + run_subagent_global_instruction_case(/*fork_context*/ true).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fresh_subagent_uses_creation_time_instructions_without_parent_history() -> Result<()> { + skip_if_no_network!(Ok(())); + run_subagent_global_instruction_case(/*fork_context*/ false).await +} + +async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> { + // Set up matched responses for the parent seed, spawn call, child turn, and parent follow-up. + let server = responses::start_mock_server().await; + let parent_prompt = if fork_context { + SPAWN_PARENT_PROMPT + } else { + SPAWN_FRESH_PARENT_PROMPT + }; + let seed_mock = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| request_body_contains(request, SPAWN_SEED_PROMPT), + responses::sse(vec![ + responses::ev_response_created("seed-response"), + responses::ev_assistant_message("seed-message", "seeded"), + responses::ev_completed("seed-response"), + ]), + ) + .await; + let spawn_args = serde_json::to_string(&json!({ + "message": SPAWN_CHILD_PROMPT, + "fork_context": fork_context, + }))?; + let spawn_mock = responses::mount_sse_once_match( + &server, + move |request: &wiremock::Request| request_body_contains(request, parent_prompt), + responses::sse(vec![ + responses::ev_response_created("spawn-response"), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + "multi_agent_v1", + "spawn_agent", + &spawn_args, + ), + responses::ev_completed("spawn-response"), + ]), + ) + .await; + let child_mock = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + request_body_contains(request, SPAWN_CHILD_PROMPT) + && !request_body_contains(request, SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("child-response"), + responses::ev_assistant_message("child-message", "done"), + responses::ev_completed("child-response"), + ]), + ) + .await; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| request_body_contains(request, SPAWN_CALL_ID), + responses::sse(vec![ + responses::ev_response_created("spawn-follow-up-response"), + responses::ev_assistant_message("spawn-follow-up-message", "child started"), + responses::ev_completed("spawn-follow-up-response"), + ]), + ) + .await; + + // Create the parent thread, record its source, and seed the history inherited by the child. + let home = Arc::new(TempDir::new()?); + let source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + OLD_GLOBAL_INSTRUCTIONS, + )?; + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(|config| { + let _ = config.features.enable(Feature::Collab); + let _ = config.features.disable(Feature::EnableRequestCompression); + }); + let test = builder.build(&server).await?; + + // Assert the parent reports the creation-time source before spawning. + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "parent reports the creation-time global source before spawning" + ); + test.submit_turn(SPAWN_SEED_PROMPT).await?; + let seed_request = seed_mock.single_request(); + + // Add a preferred override, then spawn a full-history child while observing its thread ID. + let new_source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_OVERRIDE_FILENAME, + NEW_GLOBAL_INSTRUCTIONS, + )?; + assert_ne!(source, new_source); + let mut created_threads = test.thread_manager.subscribe_thread_created(); + test.submit_turn(parent_prompt).await?; + let child_thread_id = tokio::time::timeout(Duration::from_secs(10), created_threads.recv()) + .await + .map_err(|_| anyhow!("timed out waiting for the subagent thread"))??; + let child_thread = test.thread_manager.get_thread(child_thread_id).await?; + let spawn_request = spawn_mock.single_request(); + let child_request = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Some(request) = child_mock.requests().into_iter().find(|request| { + request + .message_input_texts("user") + .iter() + .any(|text| text == SPAWN_CHILD_PROMPT) + }) { + break request; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .map_err(|_| anyhow!("timed out waiting for the subagent request"))?; + + // Assert parent and child report and render the parent's creation-time snapshot exactly once. + let expected_fragment = + expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&seed_request, &expected_fragment); + assert_single_instruction_fragment(&spawn_request, &expected_fragment); + assert_single_instruction_fragment(&child_request, &expected_fragment); + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "running parent retains the creation-time global source after spawning" + ); + assert_eq!( + child_thread.instruction_sources().await, + vec![source], + "subagent reports the parent's creation-time source" + ); + if fork_context { + let seed_input = seed_request.input(); + let child_input = child_request.input(); + assert_eq!( + child_input.get(..seed_input.len()), + Some(seed_input.as_slice()), + "forked subagent should replay the parent's original structured input prefix" + ); + } else { + let child_user_texts = child_request.message_input_texts("user"); + assert_eq!( + child_user_texts + .iter() + .filter(|text| text.as_str() == SPAWN_SEED_PROMPT) + .count(), + 0, + "fresh-context subagent should omit parent user history; observed: {child_user_texts:?}" + ); + assert_eq!( + child_user_texts + .iter() + .filter(|text| text.as_str() == SPAWN_CHILD_PROMPT) + .count(), + 1, + "fresh-context subagent should contain its own prompt exactly once; observed: {child_user_texts:?}" + ); + } + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index ac497aff3b4c..213efbf1a91c 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,4 +1,6 @@ #![allow(clippy::expect_used)] +use anyhow::Result; +use anyhow::anyhow; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; @@ -23,11 +25,13 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; use core_test_support::hooks::trust_discovered_hooks; +use core_test_support::responses; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; @@ -57,6 +61,8 @@ use serde_json::Value; use serde_json::json; use std::fs; use std::path::Path; +use std::sync::Arc; +use tempfile::TempDir; use wiremock::MockServer; // --- Test helpers ----------------------------------------------------------- @@ -3998,3 +4004,382 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() ) ); } + +const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; +const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; +const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; +const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; +const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; + +fn write_global_file( + home: &TempDir, + filename: &str, + contents: impl AsRef<[u8]>, +) -> Result { + let path = home.path().join(filename); + std::fs::write(&path, contents)?; + Ok(path.abs()) +} + +fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { + request + .message_input_texts("user") + .into_iter() + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .collect() +} + +fn instruction_fragments_in_items(items: &[Value]) -> Vec { + items + .iter() + .filter(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user") + }) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|span| span.get("text").and_then(Value::as_str)) + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .map(str::to_string) + .collect() +} + +fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { + let cwd = cwd.as_path().display(); + format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") +} + +fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) { + assert_eq!(instruction_fragments(request), vec![expected.to_string()]); +} + +fn replacement_history_from_rollout(path: &Path) -> Result> { + let rollout_text = fs::read_to_string(path)?; + let mut replacement_history = None; + for line in rollout_text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let entry: RolloutLine = serde_json::from_str(line)?; + if let RolloutItem::Compacted(compacted) = entry.item + && let Some(items) = compacted.replacement_history + { + replacement_history = Some( + items + .into_iter() + .map(serde_json::to_value) + .collect::, _>>()?, + ); + } + } + replacement_history.ok_or_else(|| anyhow!("expected rollout replacement history")) +} + +fn remote_v2_compaction_response() -> String { + responses::sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": REMOTE_V2_SUMMARY, + } + }), + responses::ev_completed("remote-v2-compact-response"), + ]) +} + +fn local_compaction_provider(server: &wiremock::MockServer) -> ModelProviderInfo { + let mut provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + provider.name = "OpenAI-compatible test provider".to_string(); + provider.base_url = Some(format!("{}/v1", server.uri())); + provider.supports_websockets = false; + provider +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { + // Set up an initial turn, a manual compaction response, and a post-compaction turn. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("first-response"), + responses::ev_completed("first-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("compact-response"), + responses::ev_assistant_message("compact-message", "summary"), + responses::ev_completed("compact-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("follow-up-response"), + responses::ev_completed("follow-up-response"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + OLD_GLOBAL_INSTRUCTIONS, + )?; + let provider = local_compaction_provider(&server); + + // Create the thread with the old global source loaded into its instruction snapshot. + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(move |config| { + config.model_provider = provider; + }); + let test = builder.build(&server).await?; + + // Assert the pre-compaction source list points at the creation-time file. + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "thread reports the creation-time global source before compaction" + ); + + // Materialize the old snapshot, rewrite the selected file in place, and manually compact. + test.submit_turn("first turn").await?; + let rewritten_source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + NEW_GLOBAL_INSTRUCTIONS, + )?; + assert_eq!(source, rewritten_source); + + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("after compact").await?; + + // Assert ordinary and compact turns keep the old rendering even though the reported source + // path now contains new text. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let expected_fragment = + expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &expected_fragment); + assert_single_instruction_fragment(&requests[1], &expected_fragment); + assert_single_instruction_fragment(&requests[2], &expected_fragment); + assert_eq!( + test.codex.instruction_sources().await, + vec![source], + "thread retains the creation-time global source after compaction" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { + // Set up a turn that crosses the auto-compaction limit and a post-compaction response. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_function_call("call-1", "unsupported_tool", "{}"), + responses::ev_completed_with_tokens("first-response", /*total_tokens*/ 96), + ]), + responses::sse(vec![ + responses::ev_assistant_message("compact-message", "summary"), + responses::ev_completed_with_tokens("compact-response", /*total_tokens*/ 10), + ]), + responses::sse(vec![ + responses::ev_assistant_message("final-message", "done"), + responses::ev_completed_with_tokens("follow-up-response", /*total_tokens*/ 10), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + OLD_GLOBAL_INSTRUCTIONS, + )?; + let provider = local_compaction_provider(&server); + + // Create the thread with the old global source loaded into its instruction snapshot. + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(move |config| { + config.model_provider = provider; + config.model_context_window = Some(100); + config.model_auto_compact_token_limit = Some(90); + }); + let test = builder.build(&server).await?; + + // Assert the pre-compaction source list points at the creation-time file. + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "thread reports the creation-time global source before mid-turn compaction" + ); + + // Add a preferred override before the turn triggers automatic mid-turn compaction. + let new_source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_OVERRIDE_FILENAME, + NEW_GLOBAL_INSTRUCTIONS, + )?; + assert_ne!(source, new_source); + test.submit_turn("trigger mid-turn compaction").await?; + + // Assert the initial, compact, and resumed requests all keep the old snapshot and source. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let expected_fragment = + expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &expected_fragment); + assert_single_instruction_fragment(&requests[1], &expected_fragment); + assert_single_instruction_fragment(&requests[2], &expected_fragment); + assert_eq!( + test.codex.instruction_sources().await, + vec![source], + "thread retains the creation-time global source after mid-turn compaction" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_mutation() +-> Result<()> { + skip_if_no_network!(Ok(())); + + // Set up an ordinary turn, a remote-v2 compact response, and a post-compaction turn. + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("remote-v2-initial-response"), + responses::ev_completed("remote-v2-initial-response"), + ]), + remote_v2_compaction_response(), + responses::sse(vec![ + responses::ev_response_created("remote-v2-follow-up-response"), + responses::ev_completed("remote-v2-follow-up-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("remote-v2-resumed-response"), + responses::ev_completed("remote-v2-resumed-response"), + ]), + ], + ) + .await; + let home = Arc::new(TempDir::new()?); + let source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + OLD_GLOBAL_INSTRUCTIONS, + )?; + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + let _ = config.features.enable(Feature::RemoteCompactionV2); + }); + let test = builder.build(&server).await?; + + // Materialize the old snapshot, rewrite the selected file in place, and compact remotely. + test.submit_turn("before remote v2 compaction").await?; + let rewritten_source = write_global_file( + home.as_ref(), + GLOBAL_AGENTS_FILENAME, + NEW_GLOBAL_INSTRUCTIONS, + )?; + assert_eq!(source, rewritten_source); + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("after remote v2 compaction").await?; + test.codex.flush_rollout().await?; + + // Assert the compact request, installed replacement history, and follow-up all keep the + // creation-time item despite the file-backed source now containing new text. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let old_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); + assert_single_instruction_fragment(&requests[0], &old_fragment); + assert_single_instruction_fragment(&requests[1], &old_fragment); + assert_single_instruction_fragment(&requests[2], &old_fragment); + assert_eq!( + requests[1].input().last(), + Some(&json!({"type": "compaction_trigger"})), + "remote-v2 compact request should append exactly one compaction trigger" + ); + let rollout_path = test.codex.rollout_path().expect("rollout path"); + let replacement_history = replacement_history_from_rollout(&rollout_path)?; + assert_eq!( + instruction_fragments_in_items(&replacement_history), + Vec::::new(), + "remote-v2 replacement history currently omits the global-instruction fragment" + ); + assert_eq!( + test.codex.instruction_sources().await, + vec![source.clone()], + "running thread retains the selected same-path source" + ); + assert_eq!( + fs::read_to_string(source.as_path())?, + NEW_GLOBAL_INSTRUCTIONS, + "the selected source path should contain the rewritten text" + ); + + // Cold-resume the persisted replacement history with freshly loaded same-path configuration. + test.codex.submit(Op::Shutdown).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + let resumed_cwd = test.config.cwd.clone(); + let mut resume_builder = test_codex() + .with_home(Arc::clone(&home)) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config.cwd = resumed_cwd; + let _ = config.features.enable(Feature::RemoteCompactionV2); + }); + let resumed = resume_builder + .resume(&server, Arc::clone(&home), rollout_path) + .await?; + resumed + .submit_turn("after remote v2 compaction cold resume") + .await?; + + // Modern replacement-history resume replays the persisted checkpoint and its later old-context + // suffix even though the same source path now contains new text. + let requests = response_mock.requests(); + assert_eq!(requests.len(), 4); + assert_single_instruction_fragment(&requests[3], &old_fragment); + let resumed_input = requests[3].input(); + assert_eq!( + resumed_input.get(..replacement_history.len()), + Some(replacement_history.as_slice()), + "remote-v2 cold resume should replay persisted replacement history verbatim" + ); + let post_compact_input = requests[2].input(); + assert_eq!( + resumed_input.get(..post_compact_input.len()), + Some(post_compact_input.as_slice()), + "remote-v2 cold resume should replay the complete post-compaction structured prefix" + ); + assert_eq!( + resumed.codex.instruction_sources().await, + vec![source], + "cold-resumed thread reports the same rewritten source path" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 83e0052a2e50..234fb8a2f0bf 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -119,7 +119,6 @@ mod unified_exec; #[cfg(unix)] mod unified_exec_zsh_fork_approvals; mod unstable_features_warning; -mod user_instructions; mod user_notification; mod user_shell_cmd; mod view_image; diff --git a/codex-rs/core/tests/suite/user_instructions.rs b/codex-rs/core/tests/suite/user_instructions.rs deleted file mode 100644 index 55d362fbe8ff..000000000000 --- a/codex-rs/core/tests/suite/user_instructions.rs +++ /dev/null @@ -1,1020 +0,0 @@ -use std::fs; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Result; -use anyhow::anyhow; -use codex_core::ForkSnapshot; -use codex_features::Feature; -use codex_login::CodexAuth; -use codex_model_provider_info::ModelProviderInfo; -use codex_model_provider_info::built_in_model_providers; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::RolloutLine; -use codex_protocol::user_input::UserInput; -use codex_utils_absolute_path::AbsolutePathBuf; -use core_test_support::load_default_config_for_test; -use core_test_support::responses; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event; -use core_test_support::wait_for_event_match; -use pretty_assertions::assert_eq; -use serde_json::Value; -use serde_json::json; -use tempfile::TempDir; - -const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; -const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; -const GLOBAL_INSTRUCTIONS: &str = "global instructions"; -const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; -const NEW_PROJECT_INSTRUCTIONS: &str = "new project instructions"; -const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; -const PROJECT_INSTRUCTIONS: &str = "project instructions"; -const PROJECT_SEPARATOR: &str = "--- project-doc ---"; -const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; -const SPAWN_CALL_ID: &str = "spawn-global-instructions-child"; -const SPAWN_CHILD_PROMPT: &str = "inspect inherited global instructions"; -const SPAWN_FRESH_PARENT_PROMPT: &str = "spawn a child with fresh context"; -const SPAWN_PARENT_PROMPT: &str = "spawn a child with the parent context"; -const SPAWN_SEED_PROMPT: &str = "seed parent history"; - -fn write_global(home: &TempDir, contents: impl AsRef<[u8]>) -> Result { - write_global_file(home, GLOBAL_AGENTS_FILENAME, contents) -} - -fn write_global_override(home: &TempDir, contents: impl AsRef<[u8]>) -> Result { - write_global_file(home, GLOBAL_AGENTS_OVERRIDE_FILENAME, contents) -} - -fn write_global_file( - home: &TempDir, - filename: &str, - contents: impl AsRef<[u8]>, -) -> Result { - let path = home.path().join(filename); - std::fs::write(&path, contents)?; - AbsolutePathBuf::try_from(path).map_err(Into::into) -} - -fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { - request - .message_input_texts("user") - .into_iter() - .filter(|text| text.starts_with("# AGENTS.md instructions for ")) - .collect() -} - -fn instruction_fragments_in_items(items: &[Value]) -> Vec { - items - .iter() - .filter(|item| { - item.get("type").and_then(Value::as_str) == Some("message") - && item.get("role").and_then(Value::as_str) == Some("user") - }) - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .filter_map(|span| span.get("text").and_then(Value::as_str)) - .filter(|text| text.starts_with("# AGENTS.md instructions for ")) - .map(str::to_string) - .collect() -} - -fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { - let cwd = cwd.as_path().display(); - format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") -} - -fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) { - assert_eq!(instruction_fragments(request), vec![expected.to_string()]); -} - -fn replacement_history_from_rollout(path: &Path) -> Result> { - let rollout_text = fs::read_to_string(path)?; - let mut replacement_history = None; - for line in rollout_text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - let entry: RolloutLine = serde_json::from_str(line)?; - if let RolloutItem::Compacted(compacted) = entry.item - && let Some(items) = compacted.replacement_history - { - replacement_history = Some( - items - .into_iter() - .map(serde_json::to_value) - .collect::, _>>()?, - ); - } - } - replacement_history.ok_or_else(|| anyhow!("expected rollout replacement history")) -} - -fn rewrite_compaction_as_legacy(path: &Path) -> Result<()> { - let rollout_text = fs::read_to_string(path)?; - let mut rewritten = Vec::new(); - let mut compacted_items = 0; - for line in rollout_text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - let mut entry: RolloutLine = serde_json::from_str(line)?; - if let RolloutItem::Compacted(compacted) = &mut entry.item { - compacted.replacement_history = None; - compacted_items += 1; - } - rewritten.push(serde_json::to_string(&entry)?); - } - if compacted_items != 1 { - return Err(anyhow!( - "expected exactly one compaction to rewrite as legacy; observed {compacted_items}" - )); - } - fs::write(path, format!("{}\n", rewritten.join("\n")))?; - Ok(()) -} - -fn remote_v2_compaction_response() -> String { - responses::sse(vec![ - json!({ - "type": "response.output_item.done", - "item": { - "type": "compaction", - "encrypted_content": REMOTE_V2_SUMMARY, - } - }), - responses::ev_completed("remote-v2-compact-response"), - ]) -} - -fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { - let is_zstd = request - .headers - .get("content-encoding") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| { - value - .split(',') - .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) - }); - let body = if is_zstd { - zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok() - } else { - Some(request.body.clone()) - }; - body.and_then(|body| String::from_utf8(body).ok()) - .is_some_and(|body| body.contains(text)) -} - -fn local_compaction_provider(server: &wiremock::MockServer) -> ModelProviderInfo { - let mut provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); - provider.name = "OpenAI-compatible test provider".to_string(); - provider.base_url = Some(format!("{}/v1", server.uri())); - provider.supports_websockets = false; - provider -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { - // Set up one global source, one project source, and two ordinary model turns. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("response-1"), - responses::ev_completed("response-1"), - ]), - responses::sse(vec![ - responses::ev_response_created("response-2"), - responses::ev_completed("response-2"), - ]), - ], - ) - .await; - let home = Arc::new(TempDir::new()?); - let global_source = write_global(home.as_ref(), GLOBAL_INSTRUCTIONS)?; - - let mut builder = test_codex() - .with_home(Arc::clone(&home)) - .with_workspace_setup(|cwd, fs| async move { - fs.write_file( - &cwd.join("AGENTS.md"), - PROJECT_INSTRUCTIONS.as_bytes().to_vec(), - /*sandbox*/ None, - ) - .await?; - Ok(()) - }); - let test = builder.build_with_remote_env(&server).await?; - let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME); - let creation_sources = vec![global_source.clone(), project_source.clone()]; - - // Confirm the thread records both creation-time sources in composition order. - assert_eq!(test.codex.instruction_sources().await, creation_sources); - - // Materialize the initial snapshot, then rewrite both selected files in place before another - // ordinary turn. - test.submit_turn("first turn").await?; - let rewritten_global_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - test.fs() - .write_file( - &project_source, - NEW_PROJECT_INSTRUCTIONS.as_bytes().to_vec(), - /*sandbox*/ None, - ) - .await?; - assert_eq!( - rewritten_global_source, global_source, - "same-path mutation should retain the selected global source path" - ); - test.submit_turn("second turn").await?; - - // Assert the running thread keeps its original rendering and structured prefix even though - // both files at the reported source paths now contain different text. - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2); - let expected_contents = - format!("{GLOBAL_INSTRUCTIONS}\n\n{PROJECT_SEPARATOR}\n\n{PROJECT_INSTRUCTIONS}"); - let expected_fragment = expected_instruction_fragment(&test.config.cwd, &expected_contents); - let fragments = instruction_fragments(&requests[0]); - assert_eq!(fragments, vec![expected_fragment.clone()]); - assert_single_instruction_fragment(&requests[1], &expected_fragment); - let rendered = fragments - .into_iter() - .next() - .ok_or_else(|| anyhow!("expected one rendered instruction fragment"))?; - let global_position = rendered.find(GLOBAL_INSTRUCTIONS).ok_or_else(|| { - anyhow!( - "expected rendered instructions to contain {GLOBAL_INSTRUCTIONS:?}; observed: {rendered}" - ) - })?; - let project_position = rendered.find(PROJECT_INSTRUCTIONS).ok_or_else(|| { - anyhow!( - "expected rendered instructions to contain {PROJECT_INSTRUCTIONS:?}; observed: {rendered}" - ) - })?; - assert!( - global_position < project_position, - "global instructions should precede project instructions: {rendered}" - ); - assert!( - rendered.contains(PROJECT_SEPARATOR), - "expected rendered instructions to contain {PROJECT_SEPARATOR:?}; observed: {rendered}" - ); - assert_eq!( - test.codex.instruction_sources().await, - creation_sources, - "ordinary turns retain the creation-time source list" - ); - let first_input = requests[0].input(); - let second_input = requests[1].input(); - assert_eq!( - second_input.get(..first_input.len()), - Some(first_input.as_slice()), - "the ordinary second turn should retain the cached prefix" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> { - // Set up a malformed global instruction file and one model response. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_once( - &server, - responses::sse(vec![ - responses::ev_response_created("warning-response"), - responses::ev_completed("warning-response"), - ]), - ) - .await; - let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), b"global\xFFinstructions")?; - - // Create the thread, capture its load warning, and submit one turn for rendered output. - let mut builder = test_codex().with_home(home); - let test = builder.build(&server).await?; - let warning = wait_for_event_match(&test.codex, |event| match event { - EventMsg::Warning(warning) - if warning - .message - .contains(source.as_path().display().to_string().as_str()) => - { - Some(warning.message.clone()) - } - _ => None, - }) - .await; - test.submit_turn("inspect lossy global instructions") - .await?; - - // Assert the source is reported, the warning is specific, and rendering is lossily decoded. - assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]); - assert!( - warning.contains("invalid UTF-8"), - "expected warning to contain \"invalid UTF-8\"; observed: {warning}" - ); - let expected_fragment = - expected_instruction_fragment(&test.config.cwd, "global\u{FFFD}instructions"); - assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); - - Ok(()) -} - -// TODO(anp): Align cold-resume instruction sources with the historical instructions replayed to -// the model so the API source list and model-visible context describe the same files. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cold_resume_replays_rendered_instructions_but_reports_current_config_sources() -> Result<()> -{ - // Set up an initial turn and a later cold-resumed turn against the same rollout. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("initial-response"), - responses::ev_completed("initial-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("resumed-response"), - responses::ev_completed("resumed-response"), - ]), - ], - ) - .await; - let home = Arc::new(TempDir::new()?); - let old_source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - - // Create the initial thread and persist its creation-time instruction snapshot. - let mut initial_builder = test_codex().with_home(Arc::clone(&home)); - let initial = initial_builder.build(&server).await?; - - // Assert the pre-resume thread reports the source used to create its snapshot. - assert_eq!( - initial.codex.instruction_sources().await, - vec![old_source.clone()], - "initial thread reports the creation-time global source" - ); - initial.submit_turn("persist instructions").await?; - let rollout_path = initial - .session_configured - .rollout_path - .clone() - .expect("rollout path"); - initial.codex.submit(Op::Shutdown).await?; - wait_for_event(&initial.codex, |event| { - matches!(event, EventMsg::ShutdownComplete) - }) - .await; - - // Add a preferred override source, then cold-resume with freshly loaded configuration. - let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_ne!(old_source, new_source); - let mut resume_builder = test_codex().with_home(Arc::clone(&home)); - let resumed = resume_builder - .resume(&server, Arc::clone(&home), rollout_path) - .await?; - - // Assert the API reports the new source while model history replays the old structured prefix. - assert_eq!( - resumed.codex.instruction_sources().await, - vec![new_source], - "resume reports sources from the newly loaded config" - ); - - resumed.submit_turn("continue resumed thread").await?; - - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2); - let initial_input = requests[0].input(); - let resumed_input = requests[1].input(); - assert_eq!( - resumed_input.get(..initial_input.len()), - Some(initial_input.as_slice()), - "cold resume should replay the original structured input prefix" - ); - let expected_fragment = - expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&requests[0], &expected_fragment); - assert_single_instruction_fragment(&requests[1], &expected_fragment); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> { - // Set up a parent turn and a later fork turn against the parent's rollout. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("parent-response"), - responses::ev_completed("parent-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("fork-response"), - responses::ev_completed("fork-response"), - ]), - ], - ) - .await; - let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - - // Create the parent and persist its creation-time instruction snapshot. - let mut builder = test_codex().with_home(Arc::clone(&home)); - let parent = builder.build(&server).await?; - - // Assert the parent reports the source used to create its snapshot. - assert_eq!( - parent.codex.instruction_sources().await, - vec![source.clone()], - "parent reports the creation-time global source" - ); - parent.submit_turn("persist instructions").await?; - parent.codex.ensure_rollout_materialized().await; - parent.codex.flush_rollout().await?; - let rollout_path = parent.codex.rollout_path().expect("rollout path"); - - // Add a preferred override source, then fork with freshly loaded configuration. - let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_ne!(source, new_source); - let mut fork_config = load_default_config_for_test(home.as_ref()).await; - fork_config.cwd = parent.config.cwd.clone(); - fork_config.model = parent.config.model.clone(); - fork_config.model_provider = parent.config.model_provider.clone(); - fork_config.model_catalog = parent.config.model_catalog.clone(); - fork_config.codex_self_exe = parent.config.codex_self_exe.clone(); - let forked = parent - .thread_manager - .fork_thread( - ForkSnapshot::Interrupted, - fork_config, - rollout_path, - /*thread_source*/ None, - /*parent_trace*/ None, - ) - .await?; - - // Assert the fork reports the new source before issuing its first turn. - assert_eq!( - forked.thread.instruction_sources().await, - vec![new_source], - "fork config should reflect the newly loaded global source" - ); - - forked - .thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "continue fork".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - wait_for_event(&forked.thread, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - - // Assert the forked model request replays the parent's exact structured history. - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2); - let parent_input = requests[0].input(); - let fork_input = requests[1].input(); - assert_eq!( - fork_input.get(..parent_input.len()), - Some(parent_input.as_slice()), - "fork should replay the parent's original structured input prefix" - ); - let expected_fragment = - expected_instruction_fragment(&parent.config.cwd, OLD_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&requests[0], &expected_fragment); - assert_single_instruction_fragment(&requests[1], &expected_fragment); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() -> Result<()> { - skip_if_no_network!(Ok(())); - run_subagent_global_instruction_case(/*fork_context*/ true).await -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn fresh_subagent_uses_creation_time_instructions_without_parent_history() -> Result<()> { - skip_if_no_network!(Ok(())); - run_subagent_global_instruction_case(/*fork_context*/ false).await -} - -async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> { - // Set up matched responses for the parent seed, spawn call, child turn, and parent follow-up. - let server = responses::start_mock_server().await; - let parent_prompt = if fork_context { - SPAWN_PARENT_PROMPT - } else { - SPAWN_FRESH_PARENT_PROMPT - }; - let seed_mock = responses::mount_sse_once_match( - &server, - |request: &wiremock::Request| request_body_contains(request, SPAWN_SEED_PROMPT), - responses::sse(vec![ - responses::ev_response_created("seed-response"), - responses::ev_assistant_message("seed-message", "seeded"), - responses::ev_completed("seed-response"), - ]), - ) - .await; - let spawn_args = serde_json::to_string(&json!({ - "message": SPAWN_CHILD_PROMPT, - "fork_context": fork_context, - }))?; - let spawn_mock = responses::mount_sse_once_match( - &server, - move |request: &wiremock::Request| request_body_contains(request, parent_prompt), - responses::sse(vec![ - responses::ev_response_created("spawn-response"), - responses::ev_function_call_with_namespace( - SPAWN_CALL_ID, - "multi_agent_v1", - "spawn_agent", - &spawn_args, - ), - responses::ev_completed("spawn-response"), - ]), - ) - .await; - let child_mock = responses::mount_sse_once_match( - &server, - |request: &wiremock::Request| { - request_body_contains(request, SPAWN_CHILD_PROMPT) - && !request_body_contains(request, SPAWN_CALL_ID) - }, - responses::sse(vec![ - responses::ev_response_created("child-response"), - responses::ev_assistant_message("child-message", "done"), - responses::ev_completed("child-response"), - ]), - ) - .await; - responses::mount_sse_once_match( - &server, - |request: &wiremock::Request| request_body_contains(request, SPAWN_CALL_ID), - responses::sse(vec![ - responses::ev_response_created("spawn-follow-up-response"), - responses::ev_assistant_message("spawn-follow-up-message", "child started"), - responses::ev_completed("spawn-follow-up-response"), - ]), - ) - .await; - - // Create the parent thread, record its source, and seed the history inherited by the child. - let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - let mut builder = test_codex() - .with_home(Arc::clone(&home)) - .with_config(|config| { - let _ = config.features.enable(Feature::Collab); - let _ = config.features.disable(Feature::EnableRequestCompression); - }); - let test = builder.build(&server).await?; - - // Assert the parent reports the creation-time source before spawning. - assert_eq!( - test.codex.instruction_sources().await, - vec![source.clone()], - "parent reports the creation-time global source before spawning" - ); - test.submit_turn(SPAWN_SEED_PROMPT).await?; - let seed_request = seed_mock.single_request(); - - // Add a preferred override, then spawn a full-history child while observing its thread ID. - let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_ne!(source, new_source); - let mut created_threads = test.thread_manager.subscribe_thread_created(); - test.submit_turn(parent_prompt).await?; - let child_thread_id = tokio::time::timeout(Duration::from_secs(10), created_threads.recv()) - .await - .map_err(|_| anyhow!("timed out waiting for the subagent thread"))??; - let child_thread = test.thread_manager.get_thread(child_thread_id).await?; - let spawn_request = spawn_mock.single_request(); - let child_request = tokio::time::timeout(Duration::from_secs(10), async { - loop { - if let Some(request) = child_mock.requests().into_iter().find(|request| { - request - .message_input_texts("user") - .iter() - .any(|text| text == SPAWN_CHILD_PROMPT) - }) { - break request; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .map_err(|_| anyhow!("timed out waiting for the subagent request"))?; - - // Assert parent and child report and render the parent's creation-time snapshot exactly once. - let expected_fragment = - expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&seed_request, &expected_fragment); - assert_single_instruction_fragment(&spawn_request, &expected_fragment); - assert_single_instruction_fragment(&child_request, &expected_fragment); - assert_eq!( - test.codex.instruction_sources().await, - vec![source.clone()], - "running parent retains the creation-time global source after spawning" - ); - assert_eq!( - child_thread.instruction_sources().await, - vec![source], - "subagent reports the parent's creation-time source" - ); - if fork_context { - let seed_input = seed_request.input(); - let child_input = child_request.input(); - assert_eq!( - child_input.get(..seed_input.len()), - Some(seed_input.as_slice()), - "forked subagent should replay the parent's original structured input prefix" - ); - } else { - let child_user_texts = child_request.message_input_texts("user"); - assert_eq!( - child_user_texts - .iter() - .filter(|text| text.as_str() == SPAWN_SEED_PROMPT) - .count(), - 0, - "fresh-context subagent should omit parent user history; observed: {child_user_texts:?}" - ); - assert_eq!( - child_user_texts - .iter() - .filter(|text| text.as_str() == SPAWN_CHILD_PROMPT) - .count(), - 1, - "fresh-context subagent should contain its own prompt exactly once; observed: {child_user_texts:?}" - ); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { - // Set up an initial turn, a manual compaction response, and a post-compaction turn. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("first-response"), - responses::ev_completed("first-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("compact-response"), - responses::ev_assistant_message("compact-message", "summary"), - responses::ev_completed("compact-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("follow-up-response"), - responses::ev_completed("follow-up-response"), - ]), - ], - ) - .await; - let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - let provider = local_compaction_provider(&server); - - // Create the thread with the old global source loaded into its instruction snapshot. - let mut builder = test_codex() - .with_home(Arc::clone(&home)) - .with_config(move |config| { - config.model_provider = provider; - }); - let test = builder.build(&server).await?; - - // Assert the pre-compaction source list points at the creation-time file. - assert_eq!( - test.codex.instruction_sources().await, - vec![source.clone()], - "thread reports the creation-time global source before compaction" - ); - - // Materialize the old snapshot, rewrite the selected file in place, and manually compact. - test.submit_turn("first turn").await?; - let rewritten_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_eq!(source, rewritten_source); - - test.codex.submit(Op::Compact).await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - test.submit_turn("after compact").await?; - - // Assert ordinary and compact turns keep the old rendering even though the reported source - // path now contains new text. - let requests = response_mock.requests(); - assert_eq!(requests.len(), 3); - let expected_fragment = - expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&requests[0], &expected_fragment); - assert_single_instruction_fragment(&requests[1], &expected_fragment); - assert_single_instruction_fragment(&requests[2], &expected_fragment); - assert_eq!( - test.codex.instruction_sources().await, - vec![source], - "thread retains the creation-time global source after compaction" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { - // Set up a turn that crosses the auto-compaction limit and a post-compaction response. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_function_call("call-1", "unsupported_tool", "{}"), - responses::ev_completed_with_tokens("first-response", /*total_tokens*/ 96), - ]), - responses::sse(vec![ - responses::ev_assistant_message("compact-message", "summary"), - responses::ev_completed_with_tokens("compact-response", /*total_tokens*/ 10), - ]), - responses::sse(vec![ - responses::ev_assistant_message("final-message", "done"), - responses::ev_completed_with_tokens("follow-up-response", /*total_tokens*/ 10), - ]), - ], - ) - .await; - let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - let provider = local_compaction_provider(&server); - - // Create the thread with the old global source loaded into its instruction snapshot. - let mut builder = test_codex() - .with_home(Arc::clone(&home)) - .with_config(move |config| { - config.model_provider = provider; - config.model_context_window = Some(100); - config.model_auto_compact_token_limit = Some(90); - }); - let test = builder.build(&server).await?; - - // Assert the pre-compaction source list points at the creation-time file. - assert_eq!( - test.codex.instruction_sources().await, - vec![source.clone()], - "thread reports the creation-time global source before mid-turn compaction" - ); - - // Add a preferred override before the turn triggers automatic mid-turn compaction. - let new_source = write_global_override(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_ne!(source, new_source); - test.submit_turn("trigger mid-turn compaction").await?; - - // Assert the initial, compact, and resumed requests all keep the old snapshot and source. - let requests = response_mock.requests(); - assert_eq!(requests.len(), 3); - let expected_fragment = - expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&requests[0], &expected_fragment); - assert_single_instruction_fragment(&requests[1], &expected_fragment); - assert_single_instruction_fragment(&requests[2], &expected_fragment); - assert_eq!( - test.codex.instruction_sources().await, - vec![source], - "thread retains the creation-time global source after mid-turn compaction" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn legacy_compaction_without_replacement_history_rebuilds_current_instructions_on_resume() --> Result<()> { - // Create a current-format compacted rollout that can be rewritten to the legacy shape. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("legacy-initial-response"), - responses::ev_completed("legacy-initial-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("legacy-compact-response"), - responses::ev_assistant_message("legacy-compact-message", "legacy summary"), - responses::ev_completed("legacy-compact-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("legacy-resumed-response"), - responses::ev_completed("legacy-resumed-response"), - ]), - ], - ) - .await; - let provider = local_compaction_provider(&server); - let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - let mut initial_builder = test_codex().with_home(Arc::clone(&home)).with_config({ - let provider = provider.clone(); - move |config| config.model_provider = provider - }); - let initial = initial_builder.build(&server).await?; - - // Persist one compaction, shut down, and remove its replacement history to emulate an older - // rollout whose compacted item contains only the summary message. - initial.submit_turn("persist legacy-shaped history").await?; - initial.codex.submit(Op::Compact).await?; - wait_for_event(&initial.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - let rollout_path = initial - .session_configured - .rollout_path - .clone() - .expect("rollout path"); - initial.codex.submit(Op::Shutdown).await?; - wait_for_event(&initial.codex, |event| { - matches!(event, EventMsg::ShutdownComplete) - }) - .await; - rewrite_compaction_as_legacy(&rollout_path)?; - - // Rewrite the selected file in place and cold-resume with current configuration. - let rewritten_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_eq!(source, rewritten_source); - let mut resume_builder = test_codex() - .with_home(Arc::clone(&home)) - .with_config(move |config| config.model_provider = provider); - let resumed = resume_builder - .resume(&server, Arc::clone(&home), rollout_path) - .await?; - resumed.submit_turn("resume legacy compaction").await?; - - // Legacy reconstruction has no complete historical checkpoint, so it injects the newly loaded - // same-path instructions rather than replaying the old item. - let requests = response_mock.requests(); - assert_eq!(requests.len(), 3); - let old_fragment = expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS); - let new_fragment = expected_instruction_fragment(&resumed.config.cwd, NEW_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&requests[0], &old_fragment); - assert_single_instruction_fragment(&requests[1], &old_fragment); - assert_single_instruction_fragment(&requests[2], &new_fragment); - assert_eq!( - resumed.codex.instruction_sources().await, - vec![source], - "legacy resume reports the rewritten same-path source" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_mutation() --> Result<()> { - skip_if_no_network!(Ok(())); - - // Set up an ordinary turn, a remote-v2 compact response, and a post-compaction turn. - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("remote-v2-initial-response"), - responses::ev_completed("remote-v2-initial-response"), - ]), - remote_v2_compaction_response(), - responses::sse(vec![ - responses::ev_response_created("remote-v2-follow-up-response"), - responses::ev_completed("remote-v2-follow-up-response"), - ]), - responses::sse(vec![ - responses::ev_response_created("remote-v2-resumed-response"), - responses::ev_completed("remote-v2-resumed-response"), - ]), - ], - ) - .await; - let home = Arc::new(TempDir::new()?); - let source = write_global(home.as_ref(), OLD_GLOBAL_INSTRUCTIONS)?; - let mut builder = test_codex() - .with_home(Arc::clone(&home)) - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(|config| { - let _ = config.features.enable(Feature::RemoteCompactionV2); - }); - let test = builder.build(&server).await?; - - // Materialize the old snapshot, rewrite the selected file in place, and compact remotely. - test.submit_turn("before remote v2 compaction").await?; - let rewritten_source = write_global(home.as_ref(), NEW_GLOBAL_INSTRUCTIONS)?; - assert_eq!(source, rewritten_source); - test.codex.submit(Op::Compact).await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - test.submit_turn("after remote v2 compaction").await?; - test.codex.flush_rollout().await?; - - // Assert the compact request, installed replacement history, and follow-up all keep the - // creation-time item despite the file-backed source now containing new text. - let requests = response_mock.requests(); - assert_eq!(requests.len(), 3); - let old_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS); - assert_single_instruction_fragment(&requests[0], &old_fragment); - assert_single_instruction_fragment(&requests[1], &old_fragment); - assert_single_instruction_fragment(&requests[2], &old_fragment); - assert_eq!( - requests[1].input().last(), - Some(&json!({"type": "compaction_trigger"})), - "remote-v2 compact request should append exactly one compaction trigger" - ); - let rollout_path = test.codex.rollout_path().expect("rollout path"); - let replacement_history = replacement_history_from_rollout(&rollout_path)?; - assert_eq!( - instruction_fragments_in_items(&replacement_history), - Vec::::new(), - "remote-v2 replacement history currently omits the global-instruction fragment" - ); - assert_eq!( - test.codex.instruction_sources().await, - vec![source.clone()], - "running thread retains the selected same-path source" - ); - assert_eq!( - fs::read_to_string(source.as_path())?, - NEW_GLOBAL_INSTRUCTIONS, - "the selected source path should contain the rewritten text" - ); - - // Cold-resume the persisted replacement history with freshly loaded same-path configuration. - test.codex.submit(Op::Shutdown).await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::ShutdownComplete) - }) - .await; - let resumed_cwd = test.config.cwd.clone(); - let mut resume_builder = test_codex() - .with_home(Arc::clone(&home)) - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| { - config.cwd = resumed_cwd; - let _ = config.features.enable(Feature::RemoteCompactionV2); - }); - let resumed = resume_builder - .resume(&server, Arc::clone(&home), rollout_path) - .await?; - resumed - .submit_turn("after remote v2 compaction cold resume") - .await?; - - // Modern replacement-history resume replays the persisted checkpoint and its later old-context - // suffix even though the same source path now contains new text. - let requests = response_mock.requests(); - assert_eq!(requests.len(), 4); - assert_single_instruction_fragment(&requests[3], &old_fragment); - let resumed_input = requests[3].input(); - assert_eq!( - resumed_input.get(..replacement_history.len()), - Some(replacement_history.as_slice()), - "remote-v2 cold resume should replay persisted replacement history verbatim" - ); - let post_compact_input = requests[2].input(); - assert_eq!( - resumed_input.get(..post_compact_input.len()), - Some(post_compact_input.as_slice()), - "remote-v2 cold resume should replay the complete post-compaction structured prefix" - ); - assert_eq!( - resumed.codex.instruction_sources().await, - vec![source], - "cold-resumed thread reports the same rewritten source path" - ); - - Ok(()) -} From 11babeb0901576cdd8f27e9864e1f20cfe22cfd1 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Tue, 9 Jun 2026 20:13:29 +0000 Subject: [PATCH 14/14] tests(core): group suite helpers --- codex-rs/core/tests/suite/agents_md.rs | 121 ++++++++-------- codex-rs/core/tests/suite/compact.rs | 183 ++++++++++++------------- 2 files changed, 151 insertions(+), 153 deletions(-) diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 44eb0d0f08ce..88f014f757ff 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -27,6 +27,20 @@ use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; +const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; +const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; +const GLOBAL_INSTRUCTIONS: &str = "global instructions"; +const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; +const NEW_PROJECT_INSTRUCTIONS: &str = "new project instructions"; +const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; +const PROJECT_INSTRUCTIONS: &str = "project instructions"; +const PROJECT_SEPARATOR: &str = "--- project-doc ---"; +const SPAWN_CALL_ID: &str = "spawn-global-instructions-child"; +const SPAWN_CHILD_PROMPT: &str = "inspect inherited global instructions"; +const SPAWN_FRESH_PARENT_PROMPT: &str = "spawn a child with fresh context"; +const SPAWN_PARENT_PROMPT: &str = "spawn a child with the parent context"; +const SPAWN_SEED_PROMPT: &str = "seed parent history"; + async fn agents_instructions(mut builder: TestCodexBuilder) -> Result { let server = start_mock_server().await; let resp_mock = mount_sse_once( @@ -46,6 +60,52 @@ async fn agents_instructions(mut builder: TestCodexBuilder) -> Result { .ok_or_else(|| anyhow::anyhow!("instructions message not found")) } +fn write_global_file( + home: &TempDir, + filename: &str, + contents: impl AsRef<[u8]>, +) -> Result { + let path = home.path().join(filename); + std::fs::write(&path, contents)?; + Ok(path.abs()) +} + +fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { + request + .message_input_texts("user") + .into_iter() + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .collect() +} + +fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { + let cwd = cwd.as_path().display(); + format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") +} + +fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) { + assert_eq!(instruction_fragments(request), vec![expected.to_string()]); +} + +fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { + let is_zstd = request + .headers + .get("content-encoding") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) + }); + let body = if is_zstd { + zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok() + } else { + Some(request.body.clone()) + }; + body.and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn agents_override_is_preferred_over_agents_md() -> Result<()> { let instructions = @@ -282,67 +342,6 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok(()) } - -const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; -const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; -const GLOBAL_INSTRUCTIONS: &str = "global instructions"; -const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; -const NEW_PROJECT_INSTRUCTIONS: &str = "new project instructions"; -const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; -const PROJECT_INSTRUCTIONS: &str = "project instructions"; -const PROJECT_SEPARATOR: &str = "--- project-doc ---"; -const SPAWN_CALL_ID: &str = "spawn-global-instructions-child"; -const SPAWN_CHILD_PROMPT: &str = "inspect inherited global instructions"; -const SPAWN_FRESH_PARENT_PROMPT: &str = "spawn a child with fresh context"; -const SPAWN_PARENT_PROMPT: &str = "spawn a child with the parent context"; -const SPAWN_SEED_PROMPT: &str = "seed parent history"; - -fn write_global_file( - home: &TempDir, - filename: &str, - contents: impl AsRef<[u8]>, -) -> Result { - let path = home.path().join(filename); - std::fs::write(&path, contents)?; - Ok(path.abs()) -} - -fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { - request - .message_input_texts("user") - .into_iter() - .filter(|text| text.starts_with("# AGENTS.md instructions for ")) - .collect() -} - -fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { - let cwd = cwd.as_path().display(); - format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") -} - -fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) { - assert_eq!(instruction_fragments(request), vec![expected.to_string()]); -} - -fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { - let is_zstd = request - .headers - .get("content-encoding") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| { - value - .split(',') - .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) - }); - let body = if is_zstd { - zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok() - } else { - Some(request.body.clone()) - }; - body.and_then(|body| String::from_utf8(body).ok()) - .is_some_and(|body| body.contains(text)) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { // Set up one global source, one project source, and two ordinary model turns. diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 213efbf1a91c..339cadda4e98 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -84,6 +84,11 @@ const DUMMY_CALL_ID: &str = "call-multi-auto"; const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push"; const POST_AUTO_USER_MSG: &str = "post auto follow-up"; const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; +const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; +const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; +const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; +const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; +const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted."; @@ -273,6 +278,92 @@ fn non_openai_model_provider(server: &MockServer) -> ModelProviderInfo { provider } +fn write_global_file( + home: &TempDir, + filename: &str, + contents: impl AsRef<[u8]>, +) -> Result { + let path = home.path().join(filename); + std::fs::write(&path, contents)?; + Ok(path.abs()) +} + +fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { + request + .message_input_texts("user") + .into_iter() + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .collect() +} + +fn instruction_fragments_in_items(items: &[Value]) -> Vec { + items + .iter() + .filter(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user") + }) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|span| span.get("text").and_then(Value::as_str)) + .filter(|text| text.starts_with("# AGENTS.md instructions for ")) + .map(str::to_string) + .collect() +} + +fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { + let cwd = cwd.as_path().display(); + format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") +} + +fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) { + assert_eq!(instruction_fragments(request), vec![expected.to_string()]); +} + +fn replacement_history_from_rollout(path: &Path) -> Result> { + let rollout_text = fs::read_to_string(path)?; + let mut replacement_history = None; + for line in rollout_text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let entry: RolloutLine = serde_json::from_str(line)?; + if let RolloutItem::Compacted(compacted) = entry.item + && let Some(items) = compacted.replacement_history + { + replacement_history = Some( + items + .into_iter() + .map(serde_json::to_value) + .collect::, _>>()?, + ); + } + } + replacement_history.ok_or_else(|| anyhow!("expected rollout replacement history")) +} + +fn remote_v2_compaction_response() -> String { + responses::sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": REMOTE_V2_SUMMARY, + } + }), + responses::ev_completed("remote-v2-compact-response"), + ]) +} + +fn local_compaction_provider(server: &wiremock::MockServer) -> ModelProviderInfo { + let mut provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + provider.name = "OpenAI-compatible test provider".to_string(); + provider.base_url = Some(format!("{}/v1", server.uri())); + provider.supports_websockets = false; + provider +} + fn model_info_with_context_window(slug: &str, context_window: i64) -> ModelInfo { let models_response = bundled_models_response() .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); @@ -4005,98 +4096,6 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() ); } -const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md"; -const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md"; -const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions"; -const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions"; -const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; - -fn write_global_file( - home: &TempDir, - filename: &str, - contents: impl AsRef<[u8]>, -) -> Result { - let path = home.path().join(filename); - std::fs::write(&path, contents)?; - Ok(path.abs()) -} - -fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { - request - .message_input_texts("user") - .into_iter() - .filter(|text| text.starts_with("# AGENTS.md instructions for ")) - .collect() -} - -fn instruction_fragments_in_items(items: &[Value]) -> Vec { - items - .iter() - .filter(|item| { - item.get("type").and_then(Value::as_str) == Some("message") - && item.get("role").and_then(Value::as_str) == Some("user") - }) - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .filter_map(|span| span.get("text").and_then(Value::as_str)) - .filter(|text| text.starts_with("# AGENTS.md instructions for ")) - .map(str::to_string) - .collect() -} - -fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { - let cwd = cwd.as_path().display(); - format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") -} - -fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) { - assert_eq!(instruction_fragments(request), vec![expected.to_string()]); -} - -fn replacement_history_from_rollout(path: &Path) -> Result> { - let rollout_text = fs::read_to_string(path)?; - let mut replacement_history = None; - for line in rollout_text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - let entry: RolloutLine = serde_json::from_str(line)?; - if let RolloutItem::Compacted(compacted) = entry.item - && let Some(items) = compacted.replacement_history - { - replacement_history = Some( - items - .into_iter() - .map(serde_json::to_value) - .collect::, _>>()?, - ); - } - } - replacement_history.ok_or_else(|| anyhow!("expected rollout replacement history")) -} - -fn remote_v2_compaction_response() -> String { - responses::sse(vec![ - json!({ - "type": "response.output_item.done", - "item": { - "type": "compaction", - "encrypted_content": REMOTE_V2_SUMMARY, - } - }), - responses::ev_completed("remote-v2-compact-response"), - ]) -} - -fn local_compaction_provider(server: &wiremock::MockServer) -> ModelProviderInfo { - let mut provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); - provider.name = "OpenAI-compatible test provider".to_string(); - provider.base_url = Some(format!("{}/v1", server.uri())); - provider.supports_websockets = false; - provider -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Result<()> { // Set up an initial turn, a manual compaction response, and a post-compaction turn.