Always use streamed remote compaction for supported providers (#44255)

## What changed

- Route manual and automatic remote compaction through the v2 implementation.
- Retire the `remote_compaction_v2` feature toggle while continuing to advertise it in the model client beta features header.
- Migrate compaction tests from `/responses/compact` to streamed `/responses` requests with `compaction_trigger` items and remove legacy parity coverage.

## Testing

Add streamed compaction coverage for tool search output trimming and session base instruction token estimates. Update app-server tests to verify started/completed notifications for both local and streamed remote compaction.

GitOrigin-RevId: cea392a6166a9b116ff83708fd9126d5546476e4
This commit is contained in:
rka-oai authored and copyberry committed 2026-09-09 18:04:04 +00:00
1 parent 2617ed2e1c
commit 3dc1e2a584
25 files changed
+675 -5635

No files matched your search

+57 -149
View File
@@ -1,7 +1,7 @@
//! End-to-end compaction flow tests.
//!
//! Phases:
//! 1) Arrange: mock responses/compact endpoints + config.
//! 1) Arrange: mock Responses endpoints + config.
//! 2) Act: start a thread and submit multiple turns to trigger auto-compaction.
//! 3) Assert: verify item/started + item/completed notifications for context compaction.
@@ -30,14 +30,12 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_features::Feature;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_utils_absolute_path::test_support::PathExt;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
@@ -50,8 +48,16 @@ const AUTO_COMPACT_LIMIT: i64 = 1_000;
const COMPACT_PROMPT: &str = "Summarize the conversation.";
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
#[derive(Clone, Copy)]
enum CompactionRoute {
Local,
Remote,
}
#[test_case(CompactionRoute::Local; "local")]
#[test_case(CompactionRoute::Remote; "streamed remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> {
async fn auto_compaction_emits_started_and_completed_items(route: CompactionRoute) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -63,99 +69,39 @@ async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()>
responses::ev_assistant_message("m2", "SECOND_REPLY"),
responses::ev_completed_with_tokens("r2", /*total_tokens*/ 330_000),
]);
let summary = match route {
CompactionRoute::Local => responses::ev_assistant_message("m3", "LOCAL_SUMMARY"),
CompactionRoute::Remote => serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "compaction",
"encrypted_content": "ENCRYPTED_COMPACTION_SUMMARY",
},
}),
};
let sse3 = responses::sse(vec![
responses::ev_assistant_message("m3", "LOCAL_SUMMARY"),
summary,
responses::ev_completed_with_tokens("r3", /*total_tokens*/ 200),
]);
let sse4 = responses::sse(vec![
responses::ev_assistant_message("m4", "FINAL_REPLY"),
responses::ev_completed_with_tokens("r4", /*total_tokens*/ 120),
]);
responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await;
let requests = responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await;
let codex_home = TempDir::new()?;
compaction_config(&server.uri(), AUTO_COMPACT_LIMIT).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let thread_id = start_thread(&mut mcp).await?;
for message in ["first", "second", "third"] {
send_turn_and_wait(&mut mcp, &thread_id, message).await?;
let mut config = compaction_config(&server.uri(), /*auto_compact_limit*/ 200_000);
if let CompactionRoute::Remote = route {
config = config
.with_provider_name("OpenAI")
.with_provider_config("requires_openai_auth = true");
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
}
let started = wait_for_context_compaction_started(&mut mcp).await?;
let completed = wait_for_context_compaction_completed(&mut mcp).await?;
let ThreadItem::ContextCompaction { id: started_id } = started.item else {
unreachable!("started item should be context compaction");
};
let ThreadItem::ContextCompaction { id: completed_id } = completed.item else {
unreachable!("completed item should be context compaction");
};
assert_eq!(started.thread_id, thread_id);
assert_eq!(completed.thread_id, thread_id);
assert_eq!(started_id, completed_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()> {
skip_if_no_network!(Ok(()));
const REMOTE_AUTO_COMPACT_LIMIT: i64 = 200_000;
let server = responses::start_mock_server().await;
let sse1 = responses::sse(vec![
responses::ev_assistant_message("m1", "FIRST_REPLY"),
responses::ev_completed_with_tokens("r1", /*total_tokens*/ 70_000),
]);
let sse2 = responses::sse(vec![
responses::ev_assistant_message("m2", "SECOND_REPLY"),
responses::ev_completed_with_tokens("r2", /*total_tokens*/ 330_000),
]);
let sse3 = responses::sse(vec![
responses::ev_assistant_message("m3", "FINAL_REPLY"),
responses::ev_completed_with_tokens("r3", /*total_tokens*/ 120),
]);
let responses_log = responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3]).await;
let compacted_history = vec![
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "REMOTE_COMPACT_SUMMARY".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Compaction {
id: None,
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
internal_chat_message_metadata_passthrough: None,
},
];
let compact_mock = responses::mount_compact_json_once(
&server,
serde_json::json!({ "output": compacted_history }),
)
.await;
let codex_home = TempDir::new()?;
compaction_config(&server.uri(), REMOTE_AUTO_COMPACT_LIMIT)
.disable_feature(Feature::RemoteCompactionV2)
.with_provider_name("OpenAI")
.with_provider_config("requires_openai_auth = true")
.write(codex_home.path())?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
config.write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
@@ -182,65 +128,31 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
assert_eq!(completed.thread_id, thread_id);
assert_eq!(started_id, completed_id);
let compact_requests = compact_mock.requests();
assert_eq!(compact_requests.len(), 1);
assert_eq!(compact_requests[0].path(), "/v1/responses/compact");
let response_requests = responses_log.requests();
assert_eq!(response_requests.len(), 3);
let turn_metadata = response_requests
.iter()
.map(|request| {
request
.header("x-codex-turn-metadata")
.as_deref()
.map(parse_json_header)
.expect("turn request should include turn metadata")
})
.collect::<Vec<_>>();
for (request, metadata) in response_requests.iter().zip(&turn_metadata) {
assert_eq!(metadata["request_kind"].as_str(), Some("turn"));
assert!(
metadata["turn_id"]
.as_str()
.is_some_and(|turn_id| !turn_id.is_empty()),
"turn request should carry a non-empty turn id"
);
assert_eq!(
metadata["window_id"].as_str(),
request.header("x-codex-window-id").as_deref()
);
assert!(metadata.get("compaction").is_none());
let requests = requests.requests();
assert_eq!(requests.len(), 4);
let compact_request = &requests[2];
assert_eq!(compact_request.path(), "/v1/responses");
match route {
CompactionRoute::Local => {
assert!(
compact_request
.inputs_of_type("compaction_trigger")
.is_empty()
);
assert!(compact_request.body_contains_text(COMPACT_PROMPT));
}
CompactionRoute::Remote => {
assert_eq!(
compact_request.inputs_of_type("compaction_trigger").len(),
1
);
assert_eq!(
requests[3].inputs_of_type("compaction")[0]["encrypted_content"],
"ENCRYPTED_COMPACTION_SUMMARY"
);
}
}
let compact_metadata = compact_requests[0]
.header("x-codex-turn-metadata")
.as_deref()
.map(parse_json_header)
.expect("compact request should include turn metadata");
assert_eq!(
compact_metadata["request_kind"].as_str(),
Some("compaction")
);
assert_eq!(
compact_metadata["compaction"],
serde_json::json!({
"trigger": "auto",
"reason": "context_limit",
"implementation": "responses_compact",
"phase": "pre_turn",
"strategy": "memento",
})
);
assert_eq!(
compact_metadata["turn_id"], turn_metadata[2]["turn_id"],
"pre-turn compaction should carry the current turn id"
);
assert_eq!(
compact_metadata["window_id"].as_str(),
compact_requests[0].header("x-codex-window-id").as_deref()
);
Ok(())
}
@@ -522,10 +434,6 @@ async fn wait_for_context_compaction_completed(
}
}
fn parse_json_header(value: &str) -> serde_json::Value {
serde_json::from_str(value).expect("turn metadata should be JSON")
}
fn compaction_config(server_uri: &str, auto_compact_limit: i64) -> MockResponsesConfig {
MockResponsesConfig::new(server_uri)
.with_root_config(&format!(
@@ -385,7 +385,26 @@ async fn parent_response(
State(state): State<Arc<MockResponsesState>>,
Json(request): Json<Value>,
) -> impl IntoResponse {
let events = if request["model"] == "gpt-5.6-luna" {
let events = if request["input"].as_array().is_some_and(|input| {
input
.last()
.is_some_and(|item| item["type"] == "compaction_trigger")
}) {
assert!(state.compact_root_after_answer);
assert!(request.to_string().contains("guardian-user-input"));
vec![
responses::ev_response_created("root-compaction"),
json!({
"type": "response.output_item.done",
"item": {
"type": "compaction",
"id": "cmp_root",
"encrypted_content": "opaque root summary",
},
}),
responses::ev_completed("root-compaction"),
]
} else if request["model"] == "gpt-5.6-luna" {
luna_response(&state, request).await
} else if request
.pointer("/client_metadata/x-openai-subagent")
@@ -712,30 +731,53 @@ async fn guardian_v2_routes_scoped_tool_approvals(
let responses_url = format!("http://{}", listener.local_addr()?);
let router = Router::new()
.route("/v1/responses", get(luna_websocket).post(parent_response))
.route("/v1/responses/compact", post(|Json(request): Json<Value>| async move {
assert!(request.to_string().contains("guardian-user-input"));
Json(json!({"output": [{"type": "compaction", "id": "cmp_root", "encrypted_content": "opaque root summary"}]}))
}))
.route(
"/metrics",
post(
|State(state): State<Arc<MockResponsesState>>, body: String| async move {
if matches!(state.transcript_content, TranscriptContent::MixedEvidence) {
let payload: Value = serde_json::from_str(&body).expect("OTLP JSON");
for metric in payload["resourceMetrics"].as_array().into_iter().flatten()
.flat_map(|resource| resource["scopeMetrics"].as_array().into_iter().flatten())
for metric in payload["resourceMetrics"]
.as_array()
.into_iter()
.flatten()
.flat_map(|resource| {
resource["scopeMetrics"].as_array().into_iter().flatten()
})
.flat_map(|scope| scope["metrics"].as_array().into_iter().flatten())
.filter(|metric| matches!(metric["name"].as_str(), Some("codex.guardian.context.request_tokens" | "codex.guardian.context.section_cost")))
.filter(|metric| {
matches!(
metric["name"].as_str(),
Some(
"codex.guardian.context.request_tokens"
| "codex.guardian.context.section_cost"
)
)
})
{
let name = metric["name"].as_str().expect("context metric name");
for point in metric["histogram"]["dataPoints"].as_array().into_iter().flatten() {
let attributes = point["attributes"].as_array().expect("metric attributes");
for point in metric["histogram"]["dataPoints"]
.as_array()
.into_iter()
.flatten()
{
let attributes =
point["attributes"].as_array().expect("metric attributes");
for target in ["sync", "async"] {
if attributes.iter().any(|attr| attr["key"] == "target" && attr["value"]["stringValue"] == target) {
let mut bounds = state.context_metric_bounds.lock().expect("context metric bounds");
if attributes.iter().any(|attr| {
attr["key"] == "target"
&& attr["value"]["stringValue"] == target
}) {
let mut bounds = state
.context_metric_bounds
.lock()
.expect("context metric bounds");
bounds.insert(
(name.to_owned(), target.to_owned()),
point["explicitBounds"].as_array().and_then(|bounds| bounds.last()).and_then(Value::as_f64),
point["explicitBounds"]
.as_array()
.and_then(|bounds| bounds.last())
.and_then(Value::as_f64),
);
if bounds.len() == 4 {
state.context_metrics_recorded.notify_one();
@@ -852,7 +894,6 @@ async fn guardian_v2_routes_scoped_tool_approvals(
if matches!(lifecycle, ThreadLifecycle::RootUserInputCompaction) {
mock_config = mock_config
.with_provider_name("OpenAI")
.disable_feature(Feature::RemoteCompactionV2)
.disable_feature(Feature::TokenBudget)
.disable_feature(Feature::EnableRequestCompression);
}
@@ -14,7 +14,6 @@ use axum::Router;
use axum::extract::State;
use axum::http::header;
use axum::routing::get;
use axum::routing::post;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::GuardianApprovalReview;
@@ -184,12 +183,33 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
get(luna_websocket).post({
let parent_requests = Arc::clone(&parent_requests);
let review_requests = Arc::clone(&review_requests);
let compact_requests = Arc::clone(&compact_requests);
let checkpoint = checkpoint.clone();
move |State(classifier): State<Arc<MockResponsesState>>,
Json(request): Json<Value>| {
let parent_requests = Arc::clone(&parent_requests);
let review_requests = Arc::clone(&review_requests);
let compact_requests = Arc::clone(&compact_requests);
let checkpoint = checkpoint.clone();
async move {
let events = if request["model"] == "gpt-5.6-luna" {
let events = if request["input"].as_array().is_some_and(|input| {
input
.iter()
.any(|item| item["type"] == "compaction_trigger")
}) {
compact_requests
.lock()
.expect("request log lock")
.push(request);
vec![
responses::ev_assistant_message("summary", SUMMARY),
json!({
"type": "response.output_item.done",
"item": checkpoint,
}),
responses::ev_completed("compact"),
]
} else if request["model"] == "gpt-5.6-luna" {
luna_response(&classifier, request).await
} else if request["client_metadata"]["x-openai-subagent"] == "guardian" {
review_requests
@@ -243,29 +263,6 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
}
}),
)
.route(
"/v1/responses/compact",
post({
let compact_requests = Arc::clone(&compact_requests);
let checkpoint = checkpoint.clone();
move |Json(request): Json<Value>| {
let compact_requests = Arc::clone(&compact_requests);
let checkpoint = checkpoint.clone();
async move {
compact_requests
.lock()
.expect("request log lock")
.push(request);
Json(json!({"output": [
{"type": "message", "role": "assistant", "content": [
{"type": "output_text", "text": SUMMARY}
]},
checkpoint
]}))
}
}
}),
)
.with_state(Arc::clone(&classifier));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let responses_url = format!("http://{}", listener.local_addr()?);
@@ -288,7 +285,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
.enable_feature(Feature::GuardianApproval)
.enable_feature(Feature::GuardianReuseParentCompaction)
.disable_feature(Feature::EnableRequestCompression)
.disable_feature(Feature::RemoteCompactionV2)
.enable_feature(Feature::RemoteCompactionV2)
.disable_feature(Feature::TokenBudget)
.with_extra_config(&format!(
"[mcp_servers.{TEST_SERVER_NAME}]\nurl = \"{mcp_url}/mcp\"\ndefault_tools_approval_mode = \"prompt\"\n\n[features.guardianv2]\nenabled = true\nthread_context = {thread_context_enabled}\npersist_scores = true\nreuse_parent_compaction = {reuse_parent_compaction}\n\n[features.guardianv2.review_scope]\ncomputer_use_only = false"
@@ -616,9 +613,16 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
let output = output["output"].as_str().expect("tool output text");
assert!(output.contains(&expected_output), "{output}");
} else {
let parent_input = serde_json::to_string(&parent[index * 2 + 1]["input"])?;
assert!(!parent_input.contains(RESTRICTION));
let parent_items = parent[index * 2 + 1]["input"]
.as_array()
.expect("request input array");
let parent_input = serde_json::to_string(parent_items)?;
// V2 keeps bounded user history while replacing old tool output with a checkpoint.
assert_eq!(parent_input.contains(RESTRICTION), index <= 3);
assert!(!parent_input.contains(EVIDENCE));
assert!(!parent_items.iter().any(|item| {
item["type"] == "function_call_output" && item["call_id"] == "inspect-1"
}));
if index <= 3 {
assert!(sync_text.contains(RESTRICTION));
if matches!(context_path, ContextPath::ThreadOwned) {
@@ -652,7 +656,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
compact_output.contains(&expected_output),
"{compact_output}"
);
assert!(parent_input.contains(SUMMARY));
assert!(parent_items.contains(&checkpoint));
if matches!(context_path, ContextPath::ThreadOwned) {
assert!(
content.contains(RESTRICTION),
@@ -662,7 +666,8 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
!transcript.contains(&expected_output),
"raw tool result must not survive the parent checkpoint"
);
assert!(transcript.contains(SUMMARY));
assert!(transcript.contains(RESTRICTION));
assert!(!transcript.contains(SUMMARY));
} else {
assert!(
transcript.contains(RESTRICTION),