Codex Handbook
core/tests/suite/pending_input.rs 1051 lines
use core_test_support::test_codex::local_selections;use std::sync::Arc;use codex_core::CodexThread;use codex_features::Feature;use codex_protocol::AgentPath;use codex_protocol::items::SleepItem;use codex_protocol::items::TurnItem;use codex_protocol::models::PermissionProfile;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::InterAgentCommunication;use codex_protocol::protocol::Op;use codex_protocol::protocol::RolloutItem;use codex_protocol::protocol::RolloutLine;use codex_protocol::user_input::UserInput;use core_test_support::context_snapshot;use core_test_support::context_snapshot::ContextSnapshotOptions;use core_test_support::responses;use core_test_support::responses::ev_completed;use core_test_support::responses::ev_completed_with_tokens;use core_test_support::responses::ev_function_call;use core_test_support::responses::ev_message_item_added;use core_test_support::responses::ev_output_text_delta;use core_test_support::responses::ev_reasoning_item;use core_test_support::responses::ev_reasoning_item_added;use core_test_support::responses::ev_response_created;use core_test_support::streaming_sse::StreamingSseChunk;use core_test_support::streaming_sse::StreamingSseServer;use core_test_support::streaming_sse::start_streaming_sse_server;use core_test_support::test_codex::TestCodex;use core_test_support::test_codex::test_codex;use core_test_support::test_codex::turn_permission_fields;use core_test_support::wait_for_event;use pretty_assertions::assert_eq;use serde_json::Value;use serde_json::from_slice;use serde_json::json;use tokio::sync::oneshot;fn ev_message_item_done(id: &str, text: &str) -> Value {    serde_json::json!({        "type": "response.output_item.done",        "item": {            "type": "message",            "role": "assistant",            "id": id,            "content": [{"type": "output_text", "text": text}]        }    })}fn sse_event(event: Value) -> String {    responses::sse(vec![event])}fn message_input_texts(body: &Value, role: &str) -> Vec<String> {    body.get("input")        .and_then(Value::as_array)        .into_iter()        .flatten()        .filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))        .filter(|item| item.get("role").and_then(Value::as_str) == Some(role))        .filter_map(|item| item.get("content").and_then(Value::as_array))        .flatten()        .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))        .filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))        .collect()}fn function_call_output_text<'a>(body: &'a Value, call_id: &str) -> Option<&'a str> {    body.get("input")        .and_then(Value::as_array)?        .iter()        .find(|item| {            item.get("type").and_then(Value::as_str) == Some("function_call_output")                && item.get("call_id").and_then(Value::as_str) == Some(call_id)        })?        .get("output")?        .as_str()}fn assert_interrupted_sleep_output(output: Option<&str>) {    let Some(output) = output else {        panic!("sleep output missing");    };    let Some(wall_time) = output        .strip_prefix("Wall time: ")        .and_then(|output| output.strip_suffix(" seconds\nSleep interrupted by new input."))    else {        panic!("sleep output should include wall time");    };    assert!(        wall_time.parse::<f64>().is_ok(),        "sleep wall time should be a number"    );}fn chunk(event: Value) -> StreamingSseChunk {    StreamingSseChunk {        gate: None,        body: responses::sse(vec![event]),    }}fn gated_chunk(gate: oneshot::Receiver<()>, events: Vec<Value>) -> StreamingSseChunk {    StreamingSseChunk {        gate: Some(gate),        body: responses::sse(events),    }}fn response_completed_chunks(response_id: &str) -> Vec<StreamingSseChunk> {    vec![        chunk(ev_response_created(response_id)),        chunk(ev_completed(response_id)),    ]}async fn build_codex(server: &StreamingSseServer) -> Arc<CodexThread> {    test_codex()        .with_model("gpt-5.4")        .build_with_streaming_server(server)        .await        .expect("build streaming Codex test session")        .codex}async fn submit_user_input(codex: &CodexThread, text: &str) {    codex        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: text.to_string(),                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 input");}async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) {    let (sandbox_policy, permission_profile) =        turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());    test.codex        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: text.to_string(),                text_elements: Vec::new(),            }],            final_output_json_schema: None,            responsesapi_client_metadata: None,            additional_context: Default::default(),            thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {                environments: Some(local_selections(test.config.cwd.clone())),                approval_policy: Some(AskForApproval::Never),                sandbox_policy: Some(sandbox_policy),                permission_profile,                collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {                    mode: codex_protocol::config_types::ModeKind::Default,                    settings: codex_protocol::config_types::Settings {                        model: test.session_configured.model.clone(),                        reasoning_effort: None,                        developer_instructions: None,                    },                }),                ..Default::default()            },        })        .await        .expect("submit user turn");}async fn steer_user_input(codex: &CodexThread, text: &str) {    codex        .steer_input(            vec![UserInput::Text {                text: text.to_string(),                text_elements: Vec::new(),            }],            /*additional_context*/ Default::default(),            /*expected_turn_id*/ None,            /*client_user_message_id*/ None,            /*responsesapi_client_metadata*/ None,        )        .await        .expect("steer user input");}async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) {    codex        .submit(Op::InterAgentCommunication {            communication: InterAgentCommunication::new(                AgentPath::try_from("/root/worker").expect("worker path should parse"),                AgentPath::root(),                Vec::new(),                text.to_string(),                /*trigger_turn*/ false,            ),        })        .await        .expect("submit queue-only agent mail");    codex        .submit(Op::RealtimeConversationListVoices)        .await        .expect("submit list-voices barrier");    wait_for_event(codex, |event| {        matches!(event, EventMsg::RealtimeConversationListVoicesResponse(_))    })    .await;}async fn wait_for_reasoning_item_started(codex: &CodexThread) {    wait_for_event(codex, |event| {        matches!(            event,            EventMsg::ItemStarted(item_started)                if matches!(&item_started.item, TurnItem::Reasoning(_))        )    })    .await;}async fn wait_for_agent_message(codex: &CodexThread, text: &str) {    let final_message = wait_for_event(        codex,        |event| matches!(event, EventMsg::AgentMessage(message) if message.message == text),    )    .await;    assert!(matches!(final_message, EventMsg::AgentMessage(_)));}async fn wait_for_turn_complete(codex: &CodexThread) {    wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;}async fn wait_for_sleep_item_started(codex: &CodexThread, call_id: &str, duration_ms: u64) {    let event = wait_for_event(codex, |event| {        matches!(            event,            EventMsg::ItemStarted(started)                if matches!(&started.item, TurnItem::Sleep(item) if item.id == call_id)        )    })    .await;    let EventMsg::ItemStarted(started) = event else {        unreachable!("wait predicate only accepts item/started events");    };    let TurnItem::Sleep(item) = started.item else {        unreachable!("wait predicate only accepts sleep items");    };    assert_eq!(        item,        SleepItem {            id: call_id.to_string(),            duration_ms,        }    );}async fn wait_for_sleep_item_completed(codex: &CodexThread, call_id: &str, duration_ms: u64) {    let event = wait_for_event(codex, |event| {        matches!(            event,            EventMsg::ItemCompleted(completed)                if matches!(&completed.item, TurnItem::Sleep(item) if item.id == call_id)        )    })    .await;    let EventMsg::ItemCompleted(completed) = event else {        unreachable!("wait predicate only accepts item/completed events");    };    let TurnItem::Sleep(item) = completed.item else {        unreachable!("wait predicate only accepts sleep items");    };    assert_eq!(        item,        SleepItem {            id: call_id.to_string(),            duration_ms,        }    );}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn steer_interrupts_wait_agent_and_is_sent_in_follow_up_request() {    const WAIT_CALL_ID: &str = "wait-call";    const INITIAL_PROMPT: &str = "wait for an agent";    const STEER_PROMPT: &str = "stop waiting and continue";    let first_chunks = vec![        chunk(ev_response_created("resp-1")),        chunk(ev_function_call(            WAIT_CALL_ID,            "wait_agent",            r#"{"timeout_ms":10000}"#,        )),        chunk(ev_completed("resp-1")),    ];    let (server, _completions) =        start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;    let codex = test_codex()        .with_model("gpt-5.4")        .with_config(|config| {            config                .features                .enable(Feature::MultiAgentV2)                .expect("test config should allow feature update");        })        .build_with_streaming_server(&server)        .await        .expect("build Codex test session")        .codex;    submit_user_input(&codex, INITIAL_PROMPT).await;    wait_for_event(&codex, |event| {        matches!(event, EventMsg::CollabWaitingBegin(_))    })    .await;    steer_user_input(&codex, STEER_PROMPT).await;    wait_for_turn_complete(&codex).await;    let requests = server.requests().await;    assert_eq!(requests.len(), 2);    let second: Value = from_slice(&requests[1]).expect("parse second request");    let relevant_user_input = message_input_texts(&second, "user")        .into_iter()        .filter(|text| text == INITIAL_PROMPT || text == STEER_PROMPT)        .collect::<Vec<_>>();    assert_eq!(        relevant_user_input,        vec![INITIAL_PROMPT.to_string(), STEER_PROMPT.to_string()]    );    let wait_output = function_call_output_text(&second, WAIT_CALL_ID).expect("wait_agent output");    assert_eq!(        serde_json::from_str::<Value>(wait_output).expect("parse wait_agent output"),        json!({            "message": "Wait interrupted by new input.",            "timed_out": false,        })    );    server.shutdown().await;}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn any_new_input_interrupts_sleep() {    const FIRST_SLEEP_CALL_ID: &str = "sleep-call-1";    const SECOND_SLEEP_CALL_ID: &str = "sleep-call-2";    const SLEEP_DURATION_MS: u64 = 3_600_000;    const INITIAL_PROMPT: &str = "sleep for a while";    const STEER_PROMPT: &str = "stop sleeping and continue";    let sleep_arguments = json!({ "duration_ms": SLEEP_DURATION_MS }).to_string();    let first_chunks = vec![        chunk(ev_response_created("resp-1")),        chunk(ev_function_call(            FIRST_SLEEP_CALL_ID,            "sleep",            &sleep_arguments,        )),        chunk(ev_completed("resp-1")),    ];    let second_chunks = vec![        chunk(ev_response_created("resp-2")),        chunk(ev_function_call(            SECOND_SLEEP_CALL_ID,            "sleep",            &sleep_arguments,        )),        chunk(ev_completed("resp-2")),    ];    let (server, _completions) = start_streaming_sse_server(vec![        first_chunks,        second_chunks,        response_completed_chunks("resp-3"),    ])    .await;    let codex = test_codex()        .with_model("gpt-5.4")        .with_config(|config| {            config                .features                .enable(Feature::SleepTool)                .expect("test config should allow feature update");        })        .build_with_streaming_server(&server)        .await        .expect("build Codex test session")        .codex;    submit_user_input(&codex, INITIAL_PROMPT).await;    wait_for_sleep_item_started(&codex, FIRST_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;    steer_user_input(&codex, STEER_PROMPT).await;    wait_for_sleep_item_completed(&codex, FIRST_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;    wait_for_sleep_item_started(&codex, SECOND_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;    submit_queue_only_agent_mail(&codex, "new mailbox input").await;    wait_for_sleep_item_completed(&codex, SECOND_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;    wait_for_turn_complete(&codex).await;    let requests = server.requests().await;    assert_eq!(requests.len(), 3);    let second: Value = from_slice(&requests[1]).expect("parse second request");    let relevant_user_input = message_input_texts(&second, "user")        .into_iter()        .filter(|text| text == INITIAL_PROMPT || text == STEER_PROMPT)        .collect::<Vec<_>>();    assert_eq!(        relevant_user_input,        vec![INITIAL_PROMPT.to_string(), STEER_PROMPT.to_string()]    );    assert_interrupted_sleep_output(function_call_output_text(&second, FIRST_SLEEP_CALL_ID));    let third: Value = from_slice(&requests[2]).expect("parse third request");    assert_interrupted_sleep_output(function_call_output_text(&third, SECOND_SLEEP_CALL_ID));    codex.submit(Op::Shutdown).await.expect("shutdown session");    wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;    let rollout_path = codex.rollout_path().expect("rollout path");    let rollout = tokio::fs::read_to_string(rollout_path)        .await        .expect("read rollout");    let persisted_sleep_items = rollout        .lines()        .filter_map(|line| serde_json::from_str::<RolloutLine>(line).ok())        .filter_map(|line| match line.item {            RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => match event.item {                TurnItem::Sleep(item) => Some(item),                _ => None,            },            _ => None,        })        .collect::<Vec<_>>();    assert_eq!(        persisted_sleep_items,        vec![            SleepItem {                id: FIRST_SLEEP_CALL_ID.to_string(),                duration_ms: SLEEP_DURATION_MS,            },            SleepItem {                id: SECOND_SLEEP_CALL_ID.to_string(),                duration_ms: SLEEP_DURATION_MS,            },        ]    );    server.shutdown().await;}fn assert_two_responses_input_snapshot(snapshot_name: &str, requests: &[Vec<u8>]) {    assert_eq!(requests.len(), 2);    let options = ContextSnapshotOptions::default().strip_capability_instructions();    let first: Value = from_slice(&requests[0]).expect("parse first request");    let second: Value = from_slice(&requests[1]).expect("parse second request");    let first_items = first["input"]        .as_array()        .expect("first request input")        .clone();    let second_items = second["input"]        .as_array()        .expect("second request input")        .clone();    let snapshot = context_snapshot::format_labeled_items_snapshot(        "/responses POST bodies (input only, redacted like other suite snapshots)",        &[            ("First request", first_items.as_slice()),            ("Second request", second_items.as_slice()),        ],        &options,    );    insta::assert_snapshot!(snapshot_name, snapshot);}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]#[ignore = "TODO(aibrahim): flaky"]async fn injected_user_input_triggers_follow_up_request_with_deltas() {    let (gate_completed_tx, gate_completed_rx) = oneshot::channel();    let first_chunks = vec![        StreamingSseChunk {            gate: None,            body: sse_event(ev_response_created("resp-1")),        },        StreamingSseChunk {            gate: None,            body: sse_event(ev_message_item_added("msg-1", "")),        },        StreamingSseChunk {            gate: None,            body: sse_event(ev_output_text_delta("first ")),        },        StreamingSseChunk {            gate: None,            body: sse_event(ev_output_text_delta("turn")),        },        StreamingSseChunk {            gate: None,            body: sse_event(ev_message_item_done("msg-1", "first turn")),        },        StreamingSseChunk {            gate: Some(gate_completed_rx),            body: sse_event(ev_completed("resp-1")),        },    ];    let second_chunks = vec![        StreamingSseChunk {            gate: None,            body: sse_event(ev_response_created("resp-2")),        },        StreamingSseChunk {            gate: None,            body: sse_event(ev_completed("resp-2")),        },    ];    let (server, _completions) =        start_streaming_sse_server(vec![first_chunks, second_chunks]).await;    let codex = test_codex()        .with_model("gpt-5.4")        .build_with_streaming_server(&server)        .await        .unwrap()        .codex;    codex        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: "first prompt".into(),                text_elements: Vec::new(),            }],            final_output_json_schema: None,            responsesapi_client_metadata: None,            additional_context: Default::default(),            thread_settings: Default::default(),        })        .await        .unwrap();    wait_for_event(&codex, |event| {        matches!(event, EventMsg::AgentMessageContentDelta(_))    })    .await;    codex        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: "second prompt".into(),                text_elements: Vec::new(),            }],            final_output_json_schema: None,            responsesapi_client_metadata: None,            additional_context: Default::default(),            thread_settings: Default::default(),        })        .await        .unwrap();    let _ = gate_completed_tx.send(());    wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;    let requests = server.requests().await;    assert_eq!(requests.len(), 2);    let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");    let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request");    let first_texts = message_input_texts(&first_body, "user");    assert!(first_texts.iter().any(|text| text == "first prompt"));    assert!(!first_texts.iter().any(|text| text == "second prompt"));    let second_texts = message_input_texts(&second_body, "user");    assert!(second_texts.iter().any(|text| text == "first prompt"));    assert!(second_texts.iter().any(|text| text == "second prompt"));    server.shutdown().await;}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn queued_inter_agent_mail_triggers_follow_up_after_reasoning_item() {    let (gate_reasoning_done_tx, gate_reasoning_done_rx) = oneshot::channel();    let first_chunks = vec![        chunk(ev_response_created("resp-1")),        chunk(ev_reasoning_item_added("reason-1", &["thinking"])),        gated_chunk(            gate_reasoning_done_rx,            vec![                ev_reasoning_item("reason-1", &["thinking"], &[]),                ev_function_call(                    "call-stale",                    "shell",                    r#"{"command":"echo stale tool call"}"#,                ),                ev_message_item_added("msg-stale", ""),                ev_output_text_delta("stale final"),                ev_message_item_done("msg-stale", "stale final"),                ev_completed("resp-1"),            ],        ),    ];    let (server, _completions) =        start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;    let codex = build_codex(&server).await;    submit_user_input(&codex, "first prompt").await;    wait_for_reasoning_item_started(&codex).await;    submit_queue_only_agent_mail(&codex, "queued child update").await;    let _ = gate_reasoning_done_tx.send(());    wait_for_turn_complete(&codex).await;    let requests = server.requests().await;    assert_two_responses_input_snapshot("pending_input_queued_mail_after_reasoning", &requests);    server.shutdown().await;}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn queued_inter_agent_mail_triggers_follow_up_after_commentary_message_item() {    let (gate_message_done_tx, gate_message_done_rx) = oneshot::channel();    let first_chunks = vec![        chunk(ev_response_created("resp-1")),        chunk(ev_message_item_added("msg-1", "")),        gated_chunk(            gate_message_done_rx,            vec![                ev_output_text_delta("first answer"),                json!({                    "type": "response.output_item.done",                    "item": {                        "type": "message",                        "role": "assistant",                        "id": "msg-1",                        "content": [{"type": "output_text", "text": "first answer"}],                        "phase": "commentary",                    }                }),                ev_function_call(                    "call-stale",                    "shell",                    r#"{"command":"echo stale tool call"}"#,                ),                ev_message_item_added("msg-stale", ""),                ev_output_text_delta("stale final"),                ev_message_item_done("msg-stale", "stale final"),                ev_completed("resp-1"),            ],        ),    ];    let (server, _completions) =        start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;    let codex = build_codex(&server).await;    submit_user_input(&codex, "first prompt").await;    wait_for_event(&codex, |event| {        matches!(            event,            EventMsg::ItemStarted(item_started)                if matches!(&item_started.item, TurnItem::AgentMessage(_))        )    })    .await;    submit_queue_only_agent_mail(&codex, "queued child update").await;    let _ = gate_message_done_tx.send(());    wait_for_agent_message(&codex, "first answer").await;    wait_for_turn_complete(&codex).await;    let requests = server.requests().await;    assert_two_responses_input_snapshot("pending_input_queued_mail_after_commentary", &requests);    server.shutdown().await;}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn user_input_does_not_preempt_after_reasoning_item() {    let (gate_reasoning_done_tx, gate_reasoning_done_rx) = oneshot::channel();    let first_chunks = vec![        chunk(ev_response_created("resp-1")),        chunk(ev_reasoning_item_added("reason-1", &["thinking"])),        gated_chunk(            gate_reasoning_done_rx,            vec![                ev_reasoning_item("reason-1", &["thinking"], &[]),                ev_function_call(                    "call-preserved",                    "shell",                    r#"{"command":"echo preserved tool call"}"#,                ),                ev_message_item_added("msg-1", ""),                ev_output_text_delta("first answer"),                ev_message_item_done("msg-1", "first answer"),                ev_completed("resp-1"),            ],        ),    ];    let (server, _completions) =        start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;    let codex = build_codex(&server).await;    submit_user_input(&codex, "first prompt").await;    wait_for_reasoning_item_started(&codex).await;    steer_user_input(&codex, "second prompt").await;    let _ = gate_reasoning_done_tx.send(());    wait_for_agent_message(&codex, "first answer").await;    wait_for_turn_complete(&codex).await;    let requests = server.requests().await;    assert_two_responses_input_snapshot(        "pending_input_user_input_no_preempt_after_reasoning",        &requests,    );    server.shutdown().await;}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn steered_user_input_waits_for_model_continuation_after_mid_turn_compact() {    let first_chunks = vec![        chunk(ev_response_created("resp-1")),        chunk(ev_function_call("call-1", "test_tool", "{}")),        chunk(ev_completed_with_tokens(            "resp-1", /*total_tokens*/ 500,        )),    ];    let compact_chunks = vec![        chunk(ev_response_created("resp-compact")),        chunk(ev_message_item_done("msg-compact", "AUTO_COMPACT_SUMMARY")),        chunk(ev_completed_with_tokens(            "resp-compact",            /*total_tokens*/ 50,        )),    ];    let post_compact_continuation_chunks = vec![        chunk(ev_response_created("resp-post-compact")),        chunk(ev_message_item_added("msg-post-compact", "")),        chunk(ev_output_text_delta("resumed old task")),        chunk(ev_message_item_done("msg-post-compact", "resumed old task")),        chunk(ev_completed_with_tokens(            "resp-post-compact",            /*total_tokens*/ 60,        )),    ];    let steered_follow_up_chunks = vec![        chunk(ev_response_created("resp-steered")),        chunk(ev_message_item_done(            "msg-steered",            "processed steered prompt",        )),        chunk(ev_completed_with_tokens(            "resp-steered",            /*total_tokens*/ 70,        )),    ];    let (server, _completions) = start_streaming_sse_server(vec![        first_chunks,        compact_chunks,        post_compact_continuation_chunks,        steered_follow_up_chunks,    ])    .await;    let codex = test_codex()        .with_model("gpt-5.4")        .with_config(|config| {            config.model_provider.name = "OpenAI (test)".to_string();            config.model_provider.supports_websockets = false;            config.model_auto_compact_token_limit = Some(200);        })        .build_with_streaming_server(&server)        .await        .expect("build streaming Codex test session")        .codex;    submit_user_input(&codex, "first prompt").await;    submit_user_input(&codex, "second prompt").await;    wait_for_agent_message(&codex, "resumed old task").await;    wait_for_turn_complete(&codex).await;    let requests = server.requests().await;    assert_eq!(requests.len(), 4);    let post_compact_body: Value = from_slice(&requests[2]).expect("parse post-compact request");    let steered_body: Value = from_slice(&requests[3]).expect("parse steered request");    let post_compact_user_texts = message_input_texts(&post_compact_body, "user");    assert!(        !post_compact_user_texts            .iter()            .any(|text| text == "second prompt"),        "steered input should stay pending until the model resumes after compaction"    );    let steered_user_texts = message_input_texts(&steered_body, "user");    assert!(        steered_user_texts            .iter()            .any(|text| text == "second prompt"),        "steered input should be recorded on the request after the post-compact continuation"    );    server.shutdown().await;}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn steered_user_input_follows_compact_when_only_the_steer_needs_follow_up() {    let (gate_first_completed_tx, gate_first_completed_rx) = oneshot::channel();    let first_chunks = vec![        chunk(ev_response_created("resp-1")),        chunk(ev_message_item_added("msg-1", "")),        chunk(ev_output_text_delta("first answer")),        chunk(ev_message_item_done("msg-1", "first answer")),        gated_chunk(            gate_first_completed_rx,            vec![ev_completed_with_tokens(                "resp-1", /*total_tokens*/ 500,            )],        ),    ];    let compact_chunks = vec![        chunk(ev_response_created("resp-compact")),        chunk(ev_message_item_done("msg-compact", "AUTO_COMPACT_SUMMARY")),        chunk(ev_completed_with_tokens(            "resp-compact",            /*total_tokens*/ 50,        )),    ];    let steered_follow_up_chunks = vec![        chunk(ev_response_created("resp-steered")),        chunk(ev_message_item_done(            "msg-steered",            "processed steered prompt",        )),        chunk(ev_completed_with_tokens(            "resp-steered",            /*total_tokens*/ 70,        )),    ];    let (server, _completions) =        start_streaming_sse_server(vec![first_chunks, compact_chunks, steered_follow_up_chunks])            .await;    let codex = test_codex()        .with_model("gpt-5.4")        .with_config(|config| {            config.model_provider.name = "OpenAI (test)".to_string();            config.model_provider.supports_websockets = false;            config.model_auto_compact_token_limit = Some(200);        })        .build_with_streaming_server(&server)        .await        .expect("build streaming Codex test session")        .codex;    submit_user_input(&codex, "first prompt").await;    wait_for_agent_message(&codex, "first answer").await;    steer_user_input(&codex, "second prompt").await;    let _ = gate_first_completed_tx.send(());    wait_for_agent_message(&codex, "processed steered prompt").await;    wait_for_turn_complete(&codex).await;    let requests = server.requests().await;    assert_eq!(requests.len(), 3);    let compact_body: Value = from_slice(&requests[1]).expect("parse compact request");    let steered_body: Value = from_slice(&requests[2]).expect("parse steered request");    let compact_user_texts = message_input_texts(&compact_body, "user");    assert!(        !compact_user_texts            .iter()            .any(|text| text == "second prompt"),        "steered input should not be included in the compaction request"    );    let steered_user_texts = message_input_texts(&steered_body, "user");    assert!(        steered_user_texts            .iter()            .any(|text| text == "second prompt"),        "steered input should follow compaction without an empty resume request when the model was already done"    );    server.shutdown().await;}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn steered_user_input_waits_when_tool_output_triggers_compact_before_next_request() {    let (gate_first_completed_tx, gate_first_completed_rx) = oneshot::channel();    let large_output_command = if cfg!(windows) {        "[Console]::Out.Write([string]::new([char]'0', 4000))"    } else {        "printf '%04000d' 0"    };    let large_output_args = json!({        "command": large_output_command,        "login": false,        "timeout_ms": 2000,    })    .to_string();    let first_chunks = vec![        chunk(ev_response_created("resp-1")),        chunk(ev_function_call(            "call-1",            "shell_command",            &large_output_args,        )),        gated_chunk(            gate_first_completed_rx,            vec![ev_completed_with_tokens(                "resp-1", /*total_tokens*/ 100,            )],        ),    ];    let compact_chunks = vec![        chunk(ev_response_created("resp-compact")),        chunk(ev_message_item_done("msg-compact", "TOOL_OUTPUT_SUMMARY")),        chunk(ev_completed_with_tokens(            "resp-compact",            /*total_tokens*/ 50,        )),    ];    let post_compact_continuation_chunks = vec![        chunk(ev_response_created("resp-post-compact")),        chunk(ev_message_item_done(            "msg-post-compact",            "resumed after compacting tool output",        )),        chunk(ev_completed_with_tokens(            "resp-post-compact",            /*total_tokens*/ 60,        )),    ];    let steered_follow_up_chunks = vec![        chunk(ev_response_created("resp-steered")),        chunk(ev_message_item_done(            "msg-steered",            "processed steered prompt",        )),        chunk(ev_completed_with_tokens(            "resp-steered",            /*total_tokens*/ 70,        )),    ];    let (server, _completions) = start_streaming_sse_server(vec![        first_chunks,        compact_chunks,        post_compact_continuation_chunks,        steered_follow_up_chunks,    ])    .await;    let test = test_codex()        .with_model("gpt-5.4")        .with_config(|config| {            config.model_provider.name = "OpenAI (test)".to_string();            config.model_provider.supports_websockets = false;            config.model_auto_compact_token_limit = Some(200);        })        .build_with_streaming_server(&server)        .await        .expect("build streaming Codex test session");    let codex = test.codex.clone();    submit_danger_full_access_user_turn(&test, "first prompt").await;    wait_for_event(&codex, |event| matches!(event, EventMsg::TurnStarted(_))).await;    steer_user_input(&codex, "second prompt").await;    let _ = gate_first_completed_tx.send(());    wait_for_turn_complete(&codex).await;    let requests = server.requests().await;    assert_eq!(requests.len(), 4);    let compact_body: Value = from_slice(&requests[1]).expect("parse compact request");    let post_compact_body: Value = from_slice(&requests[2]).expect("parse post-compact request");    let steered_body: Value = from_slice(&requests[3]).expect("parse steered request");    let compact_user_texts = message_input_texts(&compact_body, "user");    assert!(        !compact_user_texts            .iter()            .any(|text| text == "second prompt"),        "steered input should not be included in the compaction request"    );    let post_compact_user_texts = message_input_texts(&post_compact_body, "user");    assert!(        !post_compact_user_texts            .iter()            .any(|text| text == "second prompt"),        "steered input should stay pending until after the compacted continuation"    );    let steered_user_texts = message_input_texts(&steered_body, "user");    assert!(        steered_user_texts            .iter()            .any(|text| text == "second prompt"),        "steered input should be recorded on the request after the post-compact continuation"    );    server.shutdown().await;}