Codex Handbook
core/tests/suite/compact_resume_fork.rs 856 lines
//! Integration tests that cover compacting, resuming, and forking conversations.//!//! Each test sets up a mocked SSE conversation and drives the conversation through//! a specific sequence of operations. After every operation we capture the//! request payload that Codex would send to the model and assert that the//! model-visible history matches the expected sequence of messages.use super::compact::COMPACT_WARNING_MESSAGE;use super::compact::FIRST_REPLY;use super::compact::SUMMARY_TEXT;use anyhow::Result;use codex_core::CodexThread;use codex_core::ThreadManager;use codex_core::compact::SUMMARIZATION_PROMPT;use codex_core::config::Config;use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;use codex_protocol::config_types::CollaborationMode;use codex_protocol::config_types::ModeKind;use codex_protocol::config_types::Settings;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::Op;use codex_protocol::protocol::WarningEvent;use codex_protocol::user_input::UserInput;use core_test_support::context_snapshot;use core_test_support::context_snapshot::ContextSnapshotOptions;use core_test_support::context_snapshot::ContextSnapshotRenderMode;use core_test_support::responses::ResponseMock;use core_test_support::responses::ResponsesRequest;use core_test_support::responses::ev_assistant_message;use core_test_support::responses::ev_completed;use core_test_support::responses::ev_response_created;use core_test_support::responses::mount_sse_once_match;use core_test_support::responses::mount_sse_sequence;use core_test_support::responses::sse;use core_test_support::test_codex::local_selections;use core_test_support::test_codex::test_codex;use core_test_support::wait_for_event;use pretty_assertions::assert_eq;use serde_json::Value;use serde_json::json;use std::sync::Arc;use tempfile::TempDir;use wiremock::MockServer;const AFTER_SECOND_RESUME: &str = "AFTER_SECOND_RESUME";const AFTER_ROLLBACK: &str = "AFTER_ROLLBACK";fn network_disabled() -> bool {    std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok()}fn body_contains_text(body: &str, text: &str) -> bool {    body.contains(&json_fragment(text))}fn json_fragment(text: &str) -> String {    serde_json::to_string(text)        .expect("serialize text to JSON")        .trim_matches('"')        .to_string()}fn normalize_line_endings_str(text: &str) -> String {    if text.contains('\r') {        text.replace("\r\n", "\n").replace('\r', "\n")    } else {        text.to_string()    }}fn extract_summary_user_text(request: &Value, summary_text: &str) -> String {    json_message_input_texts(request, "user")        .into_iter()        .find(|text| text.contains(summary_text))        .expect("expected summary message")}fn json_message_input_texts(request: &Value, role: &str) -> Vec<String> {    request        .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(role)        })        .filter_map(|item| {            item.get("content")                .and_then(Value::as_array)                .and_then(|content| content.first())                .and_then(|entry| entry.get("text"))                .and_then(Value::as_str)                .map(str::to_string)        })        .collect()}fn normalize_compact_prompts(requests: &mut [Value]) {    let normalized_summary_prompt = normalize_line_endings_str(SUMMARIZATION_PROMPT);    for request in requests {        if let Some(input) = request.get_mut("input").and_then(Value::as_array_mut) {            input.retain(|item| {                if item.get("type").and_then(Value::as_str) != Some("message")                    || item.get("role").and_then(Value::as_str) != Some("user")                {                    return true;                }                let Some(content) = item.get("content").and_then(Value::as_array) else {                    return false;                };                let Some(first) = content.first() else {                    return false;                };                let text = first                    .get("text")                    .and_then(Value::as_str)                    .unwrap_or_default();                let normalized_text = normalize_line_endings_str(text);                !(text.is_empty() || normalized_text == normalized_summary_prompt)            });        }    }}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]/// Scenario: compact an initial conversation, resume it, fork one turn back, and/// ensure the model-visible history matches expectations at each request.async fn compact_resume_and_fork_preserve_model_history_view() {    if network_disabled() {        println!("Skipping test because network is disabled in this sandbox");        return;    }    // 1. Arrange mocked SSE responses for the initial compact/resume/fork flow.    let server = MockServer::start().await;    let request_log = mount_initial_flow(&server).await;    let expected_model = "gpt-5.4";    // 2. Start a new conversation and drive it through the compact/resume/fork steps.    let (_home, config, manager, base) =        start_test_conversation(&server, Some(expected_model)).await;    user_turn(&base, "hello world").await;    compact_conversation(&base).await;    user_turn(&base, "AFTER_COMPACT").await;    let base_path = fetch_conversation_path(&base);    assert!(        base_path.exists(),        "compact+resume test expects base path {base_path:?} to exist",    );    shutdown_conversation(&base).await;    let resumed = resume_conversation(&manager, &config, base_path).await;    user_turn(&resumed, "AFTER_RESUME").await;    let resumed_path = fetch_conversation_path(&resumed);    assert!(        resumed_path.exists(),        "compact+resume test expects resumed path {resumed_path:?} to exist",    );    let forked = fork_thread(&manager, &config, resumed_path, /*nth_user_message*/ 2).await;    user_turn(&forked, "AFTER_FORK").await;    // 3. Capture the requests to the model and validate the history slices.    let mut requests = gather_request_bodies(&request_log);    normalize_compact_prompts(&mut requests);    // input after compact is a prefix of input after resume/fork    let input_after_compact = json!(requests[requests.len() - 3]["input"]);    let input_after_resume = json!(requests[requests.len() - 2]["input"]);    let input_after_fork = json!(requests[requests.len() - 1]["input"]);    let compact_arr = input_after_compact        .as_array()        .expect("input after compact should be an array");    let resume_arr = input_after_resume        .as_array()        .expect("input after resume should be an array");    let fork_arr = input_after_fork        .as_array()        .expect("input after fork should be an array");    assert!(        compact_arr.len() <= resume_arr.len(),        "after-resume input should have at least as many items as after-compact",    );    assert_eq!(compact_arr.as_slice(), &resume_arr[..compact_arr.len()]);    assert!(        compact_arr.len() <= fork_arr.len(),        "after-fork input should have at least as many items as after-compact",    );    assert_eq!(        &compact_arr.as_slice()[..compact_arr.len()],        &fork_arr[..compact_arr.len()]    );    let first_request_user_texts = json_message_input_texts(&requests[0], "user");    let first_turn_user_index = first_request_user_texts        .len()        .checked_sub(1)        .expect("first turn request missing user messages");    assert_eq!(        first_request_user_texts[first_turn_user_index],        "hello world"    );    let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index];    let summary_after_compact = extract_summary_user_text(&requests[2], SUMMARY_TEXT);    let summary_after_resume = extract_summary_user_text(&requests[3], SUMMARY_TEXT);    let summary_after_fork = extract_summary_user_text(&requests[4], SUMMARY_TEXT);    let mut expected_after_compact_user_texts =        vec!["hello world".to_string(), summary_after_compact];    expected_after_compact_user_texts.extend_from_slice(seeded_user_prefix);    expected_after_compact_user_texts.push("AFTER_COMPACT".to_string());    assert_eq!(        json_message_input_texts(&requests[2], "user"),        expected_after_compact_user_texts    );    let mut expected_after_resume_user_texts =        vec!["hello world".to_string(), summary_after_resume];    expected_after_resume_user_texts.extend_from_slice(seeded_user_prefix);    expected_after_resume_user_texts.push("AFTER_COMPACT".to_string());    let after_resume_user_texts = json_message_input_texts(&requests[3], "user");    let (after_resume_last, after_resume_prefix) = after_resume_user_texts        .split_last()        .expect("after-resume request missing user messages");    assert_eq!(after_resume_last, "AFTER_RESUME");    assert!(        after_resume_prefix.starts_with(&expected_after_resume_user_texts),        "after-resume user texts should preserve compacted history prefix"    );    let after_resume_seeded_suffix = &after_resume_prefix[expected_after_resume_user_texts.len()..];    if seeded_user_prefix.is_empty() {        assert!(            after_resume_seeded_suffix.is_empty(),            "after-resume request should not append unexpected user prefix items"        );    } else {        let mut chunks = after_resume_seeded_suffix.chunks_exact(seeded_user_prefix.len());        assert!(            chunks.remainder().is_empty(),            "after-resume suffix should be whole seeded-prefix repeats"        );        for chunk in &mut chunks {            assert_eq!(chunk, seeded_user_prefix);        }    }    let after_fork_user_texts = json_message_input_texts(&requests[4], "user");    let mut expected_after_fork_history_prefix =        vec!["hello world".to_string(), summary_after_fork];    expected_after_fork_history_prefix.extend_from_slice(seeded_user_prefix);    expected_after_fork_history_prefix.push("AFTER_COMPACT".to_string());    let (after_fork_last, after_fork_prefix) = after_fork_user_texts        .split_last()        .expect("after-fork request missing user messages");    assert_eq!(after_fork_last, "AFTER_FORK");    assert!(        after_fork_prefix.starts_with(&expected_after_fork_history_prefix),        "after-fork user texts should preserve compacted user history prefix"    );    let after_fork_seeded_suffix = &after_fork_prefix[expected_after_fork_history_prefix.len()..];    if seeded_user_prefix.is_empty() {        assert!(            after_fork_seeded_suffix.is_empty(),            "after-fork request should not append unexpected user prefix items"        );    } else {        let mut chunks = after_fork_seeded_suffix.chunks_exact(seeded_user_prefix.len());        assert!(            chunks.remainder().is_empty(),            "after-fork suffix should be whole seeded-prefix repeats"        );        for chunk in &mut chunks {            assert_eq!(chunk, seeded_user_prefix);        }    }    assert_eq!(requests.len(), 5);}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]/// Scenario: after the forked branch is compacted, resuming again should reuse/// the compacted history and only append the new user message.async fn compact_resume_after_second_compaction_preserves_history() -> Result<()> {    if network_disabled() {        println!("Skipping test because network is disabled in this sandbox");        return Ok(());    }    // 1. Arrange mocked SSE responses as a single ordered stream so assertions    // observe the real request sequence instead of per-mock duplicate captures.    let server = MockServer::start().await;    let request_log = mount_second_compact_sequence(&server).await;    // 2. Drive the conversation through compact -> resume -> fork -> compact -> resume.    let (_home, config, manager, base) = start_test_conversation(&server, /*model*/ None).await;    user_turn(&base, "hello world").await;    compact_conversation(&base).await;    user_turn(&base, "AFTER_COMPACT").await;    let base_path = fetch_conversation_path(&base);    assert!(        base_path.exists(),        "second compact test expects base path {base_path:?} to exist",    );    shutdown_conversation(&base).await;    let resumed = resume_conversation(&manager, &config, base_path).await;    user_turn(&resumed, "AFTER_RESUME").await;    let resumed_path = fetch_conversation_path(&resumed);    assert!(        resumed_path.exists(),        "second compact test expects resumed path {resumed_path:?} to exist",    );    let forked = fork_thread(&manager, &config, resumed_path, /*nth_user_message*/ 3).await;    user_turn(&forked, "AFTER_FORK").await;    compact_conversation(&forked).await;    user_turn(&forked, "AFTER_COMPACT_2").await;    let forked_path = fetch_conversation_path(&forked);    assert!(        forked_path.exists(),        "second compact test expects forked path {forked_path:?} to exist",    );    shutdown_conversation(&forked).await;    let resumed_again = resume_conversation(&manager, &config, forked_path).await;    user_turn(&resumed_again, AFTER_SECOND_RESUME).await;    let mut requests = request_log        .requests()        .into_iter()        .map(|request| request.body_json())        .collect::<Vec<_>>();    requests.iter_mut().for_each(normalize_line_endings);    normalize_compact_prompts(&mut requests);    let input_after_compact = json!(requests[requests.len() - 2]["input"]);    let input_after_resume = json!(requests[requests.len() - 1]["input"]);    // test input after compact before resume is the same as input after resume    let compact_input_array = input_after_compact        .as_array()        .expect("input after compact should be an array");    let resume_input_array = input_after_resume        .as_array()        .expect("input after resume should be an array");    assert!(        compact_input_array.len() <= resume_input_array.len(),        "after-resume input should have at least as many items as after-compact"    );    assert_eq!(        compact_input_array.as_slice(),        &resume_input_array[..compact_input_array.len()]    );    let first_request_user_texts = json_message_input_texts(&requests[0], "user");    let first_turn_user_index = first_request_user_texts        .len()        .checked_sub(1)        .expect("first turn request missing user messages");    assert_eq!(        first_request_user_texts[first_turn_user_index],        "hello world"    );    let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index];    let summary_after_second_compact =        extract_summary_user_text(&requests[requests.len() - 2], SUMMARY_TEXT);    let mut expected_after_second_compact_user_texts = vec![        "hello world".to_string(),        "AFTER_COMPACT".to_string(),        "AFTER_RESUME".to_string(),        "AFTER_FORK".to_string(),        summary_after_second_compact.clone(),    ];    expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix);    expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string());    let mut expected_fork_local_user_texts =        vec!["AFTER_FORK".to_string(), summary_after_second_compact];    expected_fork_local_user_texts.extend_from_slice(seeded_user_prefix);    expected_fork_local_user_texts.push("AFTER_COMPACT_2".to_string());    let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user");    let (final_last, final_prefix) = final_user_texts        .split_last()        .expect("after-second-resume request missing user messages");    assert_eq!(final_last, AFTER_SECOND_RESUME);    let matched_prefix_len = if let Some(start) = final_prefix        .windows(expected_after_second_compact_user_texts.len())        .position(|window| window == expected_after_second_compact_user_texts)    {        start + expected_after_second_compact_user_texts.len()    } else if let Some(start) = final_prefix        .windows(expected_fork_local_user_texts.len())        .position(|window| window == expected_fork_local_user_texts)    {        start + expected_fork_local_user_texts.len()    } else {        panic!("after-second-resume user texts should preserve post-compact user history prefix");    };    let final_seeded_suffix = &final_prefix[matched_prefix_len..];    if seeded_user_prefix.is_empty() {        assert!(            final_seeded_suffix.is_empty(),            "after-second-resume request should not append unexpected user prefix items"        );    } else {        let mut chunks = final_seeded_suffix.chunks_exact(seeded_user_prefix.len());        assert!(            chunks.remainder().is_empty(),            "after-second-resume suffix should be whole seeded-prefix repeats"        );        for chunk in &mut chunks {            assert_eq!(chunk, seeded_user_prefix);        }    }    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]/// Scenario: rolling back behind a pre-turn compaction should replay/// append-only history from the rollout file and keep earlier compacted/// history visible.async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Result<()> {    if network_disabled() {        println!("Skipping test because network is disabled in this sandbox");        return Ok(());    }    const EDITED_AFTER_COMPACT: &str = "EDITED_AFTER_COMPACT";    const SECOND_REPLY: &str = "SECOND_REPLY";    let server = MockServer::start().await;    let sse1 = sse(vec![        ev_assistant_message("m1", FIRST_REPLY),        ev_completed("r1"),    ]);    let sse2 = sse(vec![        ev_assistant_message("m2", SUMMARY_TEXT),        ev_completed("r2"),    ]);    let sse3 = sse(vec![        ev_assistant_message("m3", SECOND_REPLY),        ev_completed("r3"),    ]);    let sse4 = sse(vec![ev_completed("r4")]);    let request_log = mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await;    let (_home, _config, _manager, base) = start_test_conversation(&server, /*model*/ None).await;    user_turn(&base, "hello world").await;    compact_conversation(&base).await;    user_turn(&base, EDITED_AFTER_COMPACT).await;    base.submit(Op::ThreadRollback { num_turns: 1 })        .await        .expect("submit thread rollback");    let rollback_event =        wait_for_event(&base, |ev| matches!(ev, EventMsg::ThreadRolledBack(_))).await;    let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else {        panic!("expected thread rolled back event");    };    assert_eq!(rollback_event.num_turns, 1);    user_turn(&base, AFTER_ROLLBACK).await;    let requests = request_log.requests();    assert_eq!(requests.len(), 4);    assert!(requests[1].body_contains_text(SUMMARIZATION_PROMPT));    assert!(requests[2].body_contains_text("hello world"));    assert!(requests[2].body_contains_text(SUMMARY_TEXT));    assert!(requests[2].body_contains_text(EDITED_AFTER_COMPACT));    let after_rollback_user_texts = requests[3].message_input_texts("user");    let after_rollback_last = after_rollback_user_texts        .last()        .expect("post-rollback request missing user messages");    assert_eq!(after_rollback_last, AFTER_ROLLBACK);    assert!(        requests[3].body_contains_text("hello world"),        "the first turn should remain visible after rollback behind compaction",    );    assert!(        !requests[3].body_contains_text(EDITED_AFTER_COMPACT),        "the edited post-compaction turn should be removed by rollback",    );    assert!(        requests[3].body_contains_text(SUMMARY_TEXT),        "compaction summary should remain for the preserved first turn",    );    insta::assert_snapshot!(        "rollback_past_compaction_shapes",        context_snapshot::format_labeled_requests_snapshot(            "rollback past compaction replay after rollback",            &[                ("compaction request", &requests[1]),                ("before rollback", &requests[2]),                ("after rollback", &requests[3]),            ],            &ContextSnapshotOptions::default()                .strip_capability_instructions()                .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }),        )    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]/// Scenario: rolling back a turn that introduced persistent pre-thread settings/// diffs should trim those context updates so the next request includes them/// only once.async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> {    if network_disabled() {        println!("Skipping test because network is disabled in this sandbox");        return Ok(());    }    const MODEL: &str = "gpt-5.4";    const TURN_ONE_USER: &str = "turn 1 user";    const TURN_TWO_USER: &str = "turn 2 user";    const FOLLOWUP_USER: &str = "follow-up user";    const ROLLED_BACK_DEV_INSTRUCTIONS: &str = "ROLLED_BACK_DEV_INSTRUCTIONS";    const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD";    let server = MockServer::start().await;    let request_log = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_assistant_message("m1", "turn 1 assistant"),                ev_completed("r1"),            ]),            sse(vec![                ev_assistant_message("m2", "turn 2 assistant"),                ev_completed("r2"),            ]),            sse(vec![ev_response_created("r3"), ev_completed("r3")]),        ],    )    .await;    let (_home, config, _manager, conversation) =        start_test_conversation(&server, Some(MODEL)).await;    user_turn(&conversation, TURN_ONE_USER).await;    let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD);    std::fs::create_dir_all(&override_cwd)?;    core_test_support::submit_thread_settings(        &conversation,        codex_protocol::protocol::ThreadSettingsOverrides {            environments: Some(local_selections(override_cwd.clone())),            collaboration_mode: Some(CollaborationMode {                mode: ModeKind::Default,                settings: Settings {                    model: MODEL.to_string(),                    reasoning_effort: None,                    developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()),                },            }),            ..Default::default()        },    )    .await?;    user_turn(&conversation, TURN_TWO_USER).await;    conversation        .submit(Op::ThreadRollback { num_turns: 1 })        .await?;    let rollback_event = wait_for_event(&conversation, |ev| {        matches!(ev, EventMsg::ThreadRolledBack(_))    })    .await;    let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else {        panic!("expected thread rolled back event");    };    assert_eq!(rollback_event.num_turns, 1);    user_turn(&conversation, FOLLOWUP_USER).await;    let requests = request_log.requests();    assert_eq!(requests.len(), 3);    let before_rollback_developer_count = requests[1]        .message_input_texts("developer")        .iter()        .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))        .count();    assert_eq!(before_rollback_developer_count, 1);    assert_eq!(        requests[1]            .message_input_texts("user")            .iter()            .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))            .count(),        1    );    let after_rollback_developer_count = requests[2]        .message_input_texts("developer")        .iter()        .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))        .count();    assert_eq!(after_rollback_developer_count, 1);    let after_rollback_user_texts = requests[2].message_input_texts("user");    assert_eq!(        after_rollback_user_texts            .iter()            .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))            .count(),        1    );    assert_eq!(        after_rollback_user_texts.last().map(String::as_str),        Some(FOLLOWUP_USER)    );    insta::assert_snapshot!(        "rollback_followup_turn_trims_context_updates",        context_snapshot::format_labeled_requests_snapshot(            "rollback trims pre-turn override context updates before the follow-up request",            &[                ("rolled-back turn request", &requests[1]),                ("follow-up request after rollback", &requests[2]),            ],            &ContextSnapshotOptions::default()                .strip_capability_instructions()                .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }),        )    );    Ok(())}fn normalize_line_endings(value: &mut Value) {    match value {        Value::String(text) if text.contains('\r') => {            *text = text.replace("\r\n", "\n").replace('\r', "\n");        }        Value::Array(items) => {            for item in items {                normalize_line_endings(item);            }        }        Value::Object(map) => {            for item in map.values_mut() {                normalize_line_endings(item);            }        }        _ => {}    }}fn gather_requests(request_log: &[ResponseMock]) -> Vec<ResponsesRequest> {    request_log        .iter()        .flat_map(ResponseMock::requests)        .collect::<Vec<_>>()}fn gather_request_bodies(request_log: &[ResponseMock]) -> Vec<Value> {    let mut bodies = gather_requests(request_log)        .into_iter()        .map(|request| request.body_json())        .collect::<Vec<_>>();    bodies.iter_mut().for_each(normalize_line_endings);    bodies}async fn mount_initial_flow(server: &MockServer) -> Vec<ResponseMock> {    let sse1 = sse(vec![        ev_assistant_message("m1", FIRST_REPLY),        ev_completed("r1"),    ]);    let sse2 = sse(vec![        ev_assistant_message("m2", SUMMARY_TEXT),        ev_completed("r2"),    ]);    let sse3 = sse(vec![        ev_assistant_message("m3", "AFTER_COMPACT_REPLY"),        ev_completed("r3"),    ]);    let sse4 = sse(vec![ev_completed("r4")]);    let sse5 = sse(vec![ev_completed("r5")]);    let match_first = |req: &wiremock::Request| {        let body = std::str::from_utf8(&req.body).unwrap_or("");        body.contains("\"text\":\"hello world\"")            && !body.contains(&format!("\"text\":\"{SUMMARY_TEXT}\""))            && !body.contains("\"text\":\"AFTER_COMPACT\"")            && !body.contains("\"text\":\"AFTER_RESUME\"")            && !body.contains("\"text\":\"AFTER_FORK\"")    };    let first = mount_sse_once_match(server, match_first, sse1).await;    let match_compact = |req: &wiremock::Request| {        let body = std::str::from_utf8(&req.body).unwrap_or("");        body_contains_text(body, SUMMARIZATION_PROMPT) || body.contains(&json_fragment(FIRST_REPLY))    };    let compact = mount_sse_once_match(server, match_compact, sse2).await;    let match_after_compact = |req: &wiremock::Request| {        let body = std::str::from_utf8(&req.body).unwrap_or("");        body.contains("\"text\":\"AFTER_COMPACT\"")            && !body.contains("\"text\":\"AFTER_RESUME\"")            && !body.contains("\"text\":\"AFTER_FORK\"")    };    let after_compact = mount_sse_once_match(server, match_after_compact, sse3).await;    let match_after_resume = |req: &wiremock::Request| {        let body = std::str::from_utf8(&req.body).unwrap_or("");        body.contains("\"text\":\"AFTER_RESUME\"")    };    let after_resume = mount_sse_once_match(server, match_after_resume, sse4).await;    let match_after_fork = |req: &wiremock::Request| {        let body = std::str::from_utf8(&req.body).unwrap_or("");        body.contains("\"text\":\"AFTER_FORK\"")    };    let after_fork = mount_sse_once_match(server, match_after_fork, sse5).await;    vec![first, compact, after_compact, after_resume, after_fork]}async fn mount_second_compact_sequence(server: &MockServer) -> ResponseMock {    let sse1 = sse(vec![        ev_assistant_message("m1", FIRST_REPLY),        ev_completed("r1"),    ]);    let sse2 = sse(vec![        ev_assistant_message("m2", SUMMARY_TEXT),        ev_completed("r2"),    ]);    let sse3 = sse(vec![        ev_assistant_message("m3", "AFTER_COMPACT_REPLY"),        ev_completed("r3"),    ]);    let sse4 = sse(vec![ev_completed("r4")]);    let sse5 = sse(vec![ev_completed("r5")]);    let sse6 = sse(vec![        ev_assistant_message("m4", SUMMARY_TEXT),        ev_completed("r6"),    ]);    let sse7 = sse(vec![ev_completed("r7")]);    let sse8 = sse(vec![ev_completed("r8")]);    mount_sse_sequence(server, vec![sse1, sse2, sse3, sse4, sse5, sse6, sse7, sse8]).await}async fn start_test_conversation(    server: &MockServer,    model: Option<&str>,) -> (Arc<TempDir>, Config, Arc<ThreadManager>, Arc<CodexThread>) {    let base_url = format!("{}/v1", server.uri());    let model = model.map(str::to_string);    let mut builder = test_codex().with_config(move |config| {        config.model_provider.name = "Non-OpenAI Model provider".to_string();        config.model_provider.base_url = Some(base_url);        config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string());        if let Some(model) = model {            config.model = Some(model);        }    });    let test = Box::pin(builder.build(server))        .await        .expect("create conversation");    (test.home, test.config, test.thread_manager, test.codex)}async fn user_turn(conversation: &Arc<CodexThread>, text: &str) {    conversation        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: text.into(),                text_elements: Vec::new(),            }],            final_output_json_schema: None,            responsesapi_client_metadata: None,            additional_context: Default::default(),            thread_settings: Default::default(),        })        .await        .expect("submit user turn");    wait_for_event(conversation, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;}async fn compact_conversation(conversation: &Arc<CodexThread>) {    conversation        .submit(Op::Compact)        .await        .expect("compact conversation");    let warning_event = wait_for_event(conversation, |ev| {        matches!(            ev,            EventMsg::Warning(WarningEvent { message }) if message == COMPACT_WARNING_MESSAGE        )    })    .await;    let EventMsg::Warning(WarningEvent { message }) = warning_event else {        panic!("expected warning event after compact");    };    assert_eq!(message, COMPACT_WARNING_MESSAGE);    wait_for_event(conversation, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;}fn fetch_conversation_path(conversation: &Arc<CodexThread>) -> std::path::PathBuf {    conversation.rollout_path().expect("rollout path")}async fn shutdown_conversation(conversation: &Arc<CodexThread>) {    conversation        .shutdown_and_wait()        .await        .expect("shutdown conversation");}async fn resume_conversation(    manager: &ThreadManager,    config: &Config,    path: std::path::PathBuf,) -> Arc<CodexThread> {    let auth_manager = codex_core::test_support::auth_manager_from_auth(        codex_login::CodexAuth::from_api_key("dummy"),    );    Box::pin(manager.resume_thread_from_rollout(        config.clone(),        path,        auth_manager,        /*parent_trace*/ None,    ))    .await    .expect("resume conversation")    .thread}#[cfg(test)]async fn fork_thread(    manager: &ThreadManager,    config: &Config,    path: std::path::PathBuf,    nth_user_message: usize,) -> Arc<CodexThread> {    Box::pin(manager.fork_thread(        nth_user_message,        config.clone(),        path,        /*thread_source*/ None,        /*parent_trace*/ None,    ))    .await    .expect("fork conversation")    .thread}