[codex] Characterize global instruction lifecycle (#26830)

## Why

Global instruction behavior spans thread creation, resume, forks,
subagents, and compaction. Characterization coverage is needed before
changing those semantics so preserved history can be distinguished from
newly loaded configuration.

## What changed

- Extends the existing `agents_md` suite with fresh-thread, warning,
resume, fork, and subagent lifecycle coverage.
- Extends the existing `compact` suite with manual, mid-turn, and
remote-v2 compaction coverage.
- Asserts rendered instruction fragments, reported source paths, and
structured request history before and after instruction-file mutations.
This commit is contained in:
Adam Perry @ OpenAI
2026-06-09 13:51:57 -07:00
committed by GitHub
Unverified
parent 4ec3b8eeea
commit 9e3081be96
2 changed files with 985 additions and 1 deletions
+601 -1
View File
@@ -1,18 +1,46 @@
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;
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<String> {
let server = start_mock_server().await;
let resp_mock = mount_sse_once(
@@ -32,6 +60,52 @@ async fn agents_instructions(mut builder: TestCodexBuilder) -> Result<String> {
.ok_or_else(|| anyhow::anyhow!("instructions message not found"))
}
fn write_global_file(
home: &TempDir,
filename: &str,
contents: impl AsRef<[u8]>,
) -> Result<AbsolutePathBuf> {
let path = home.path().join(filename);
std::fs::write(&path, contents)?;
Ok(path.abs())
}
fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec<String> {
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<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>")
}
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 =
@@ -250,7 +324,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 +342,529 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu
Ok(())
}
#[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(())
}
+384
View File
@@ -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 -----------------------------------------------------------
@@ -78,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.";
@@ -267,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<AbsolutePathBuf> {
let path = home.path().join(filename);
std::fs::write(&path, contents)?;
Ok(path.abs())
}
fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec<String> {
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<String> {
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<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>")
}
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<Vec<Value>> {
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::<std::result::Result<Vec<_>, _>>()?,
);
}
}
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}"));
@@ -3998,3 +4095,290 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages()
)
);
}
#[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::<String>::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(())
}