Codex Handbook
app-server/tests/suite/v2/thread_settings_update.rs 458 lines
use anyhow::Context;use anyhow::Result;use app_test_support::TestAppServer;use app_test_support::create_final_assistant_message_sse_response;use app_test_support::create_mock_responses_server_sequence_unchecked;use app_test_support::to_response;use app_test_support::write_mock_responses_config_toml;use app_test_support::write_models_cache;use codex_app_server_protocol::JSONRPCError;use codex_app_server_protocol::JSONRPCNotification;use codex_app_server_protocol::JSONRPCResponse;use codex_app_server_protocol::RequestId;use codex_app_server_protocol::SandboxPolicy;use codex_app_server_protocol::ThreadReadParams;use codex_app_server_protocol::ThreadReadResponse;use codex_app_server_protocol::ThreadSettingsUpdateParams;use codex_app_server_protocol::ThreadSettingsUpdateResponse;use codex_app_server_protocol::ThreadSettingsUpdatedNotification;use codex_app_server_protocol::ThreadStartParams;use codex_app_server_protocol::ThreadStartResponse;use codex_app_server_protocol::TurnStartParams;use codex_app_server_protocol::TurnStartResponse;use codex_app_server_protocol::UserInput as V2UserInput;use codex_core::test_support::all_model_presets;use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;use core_test_support::responses;use pretty_assertions::assert_eq;use serde_json::Value;use std::collections::BTreeMap;use std::time::Duration;use tempfile::TempDir;use tokio::time::timeout;const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);#[tokio::test]async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Result<()> {    let server = create_mock_responses_server_sequence_unchecked(vec![        create_final_assistant_message_sse_response("done")?,    ])    .await;    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), &server.uri())?;    write_models_cache(codex_home.path())?;    let (model_id, service_tier_id) = service_tier_model_and_tier_id()?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let thread = start_thread(&mut mcp).await?.thread;    send_thread_settings_update(        &mut mcp,        ThreadSettingsUpdateParams {            thread_id: thread.id.clone(),            model: Some(model_id.clone()),            service_tier: Some(Some(service_tier_id.clone())),            ..Default::default()        },    )    .await?;    assert!(        received_response_bodies(&server).await?.is_empty(),        "settings-only update should not start a model request"    );    start_text_turn(&mut mcp, thread.id.clone()).await?;    let updated = read_thread_settings_updated(&mut mcp).await?;    assert_eq!(updated.thread_id, thread.id);    assert_eq!(updated.thread_settings.model, model_id);    assert_eq!(        updated.thread_settings.service_tier.as_deref(),        Some(service_tier_id.as_str())    );    timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    let read = read_thread_with_turns(&mut mcp, &thread.id).await?;    assert_eq!(read.thread.turns.len(), 1);    let request_bodies = received_response_bodies(&server).await?;    assert!(        request_bodies.iter().any(|body| {            body.get("model").and_then(Value::as_str) == Some(model_id.as_str())                && body.get("service_tier").and_then(Value::as_str)                    == Some(service_tier_id.as_str())        }),        "future turn did not use updated model/service tier: {request_bodies:#?}"    );    Ok(())}#[tokio::test]async fn thread_settings_update_cwd_retargets_default_environment() -> Result<()> {    let server = responses::start_mock_server().await;    let body = responses::sse(vec![        responses::ev_response_created("resp-1"),        responses::ev_assistant_message("msg-1", "done"),        responses::ev_completed("resp-1"),    ]);    let response_mock = responses::mount_sse_once(&server, body).await;    let codex_home = TempDir::new()?;    let workspace = TempDir::new()?;    create_config_toml(codex_home.path(), &server.uri())?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let thread = start_thread(&mut mcp).await?.thread;    send_thread_settings_update(        &mut mcp,        ThreadSettingsUpdateParams {            thread_id: thread.id.clone(),            cwd: Some(workspace.path().to_path_buf()),            ..Default::default()        },    )    .await?;    let updated = read_thread_settings_updated(&mut mcp).await?;    assert_eq!(updated.thread_settings.cwd.as_path(), workspace.path());    start_text_turn(&mut mcp, thread.id).await?;    timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    let environment_context = response_mock        .single_request()        .message_input_texts("user")        .into_iter()        .find(|text| text.starts_with("<environment_context>"))        .context("environment context should be model visible")?;    assert!(        environment_context.contains(&format!(            "<cwd>{}</cwd>",            workspace.path().to_string_lossy()        )),        "default environment should use the updated cwd: {environment_context}"    );    Ok(())}#[tokio::test]async fn thread_settings_update_while_turn_is_active_emits_notification() -> Result<()> {    let server = responses::start_mock_server().await;    let first_response =        responses::sse_response(create_final_assistant_message_sse_response("first done")?)            .set_delay(Duration::from_secs(2));    let _requests = responses::mount_response_sequence(&server, vec![first_response]).await;    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), &server.uri())?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let thread = start_thread(&mut mcp).await?.thread;    start_text_turn(&mut mcp, thread.id.clone()).await?;    timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_notification_message("turn/started"),    )    .await??;    send_thread_settings_update(        &mut mcp,        ThreadSettingsUpdateParams {            thread_id: thread.id.clone(),            model: Some("mock-model-4".to_string()),            ..Default::default()        },    )    .await?;    let updated = read_thread_settings_updated(&mut mcp).await?;    assert_eq!(updated.thread_id, thread.id);    assert_eq!(updated.thread_settings.model, "mock-model-4");    timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    Ok(())}#[tokio::test]async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> {    let server = create_mock_responses_server_sequence_unchecked(vec![        create_final_assistant_message_sse_response("done")?,    ])    .await;    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), &server.uri())?;    write_models_cache(codex_home.path())?;    let (model_id, service_tier_id) = service_tier_model_and_tier_id()?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let thread = start_thread(&mut mcp).await?.thread;    send_thread_settings_update(        &mut mcp,        ThreadSettingsUpdateParams {            thread_id: thread.id.clone(),            model: Some(model_id.clone()),            service_tier: Some(Some(service_tier_id.clone())),            ..Default::default()        },    )    .await?;    let set_updated = read_thread_settings_updated(&mut mcp).await?;    assert_eq!(set_updated.thread_id, thread.id);    assert_eq!(        set_updated.thread_settings.service_tier.as_deref(),        Some(service_tier_id.as_str())    );    send_thread_settings_update(        &mut mcp,        ThreadSettingsUpdateParams {            thread_id: thread.id.clone(),            service_tier: Some(None),            ..Default::default()        },    )    .await?;    let clear_updated = read_thread_settings_updated(&mut mcp).await?;    assert_eq!(clear_updated.thread_id, thread.id);    assert_eq!(clear_updated.thread_settings.model, model_id);    assert_eq!(        clear_updated.thread_settings.service_tier.as_deref(),        Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE)    );    start_text_turn(&mut mcp, thread.id).await?;    timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    let request_bodies = received_response_bodies(&server).await?;    assert!(        request_bodies.iter().any(|body| {            body.get("model").and_then(Value::as_str) == Some(model_id.as_str())                && body                    .as_object()                    .is_some_and(|object| !object.contains_key("service_tier"))        }),        "future turn did not clear service tier: {request_bodies:#?}"    );    Ok(())}#[tokio::test]async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Result<()> {    let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), &server.uri())?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let thread = start_thread(&mut mcp).await?.thread;    let request_id = mcp        .send_thread_settings_update_request(ThreadSettingsUpdateParams {            thread_id: thread.id,            sandbox_policy: Some(SandboxPolicy::DangerFullAccess),            permissions: Some(":workspace".to_string()),            ..Default::default()        })        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(        error.error.message,        "`permissions` cannot be combined with `sandboxPolicy`"    );    Ok(())}#[tokio::test]async fn turn_start_settings_override_emits_thread_settings_updated() -> Result<()> {    let server = create_mock_responses_server_sequence_unchecked(vec![        create_final_assistant_message_sse_response("done")?,    ])    .await;    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), &server.uri())?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let thread = start_thread(&mut mcp).await?.thread;    timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_notification_message("thread/started"),    )    .await??;    let turn_request_id = mcp        .send_turn_start_request(TurnStartParams {            thread_id: thread.id.clone(),            client_user_message_id: None,            input: vec![V2UserInput::Text {                text: "hello".to_string(),                text_elements: Vec::new(),            }],            model: Some("mock-model-3".to_string()),            ..Default::default()        })        .await?;    let turn_response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),    )    .await??;    let TurnStartResponse { turn } = to_response(turn_response)?;    assert!(!turn.id.is_empty());    let updated = read_thread_settings_updated(&mut mcp).await?;    assert_eq!(updated.thread_id, thread.id);    assert_eq!(updated.thread_settings.model, "mock-model-3");    timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    Ok(())}async fn send_thread_settings_update(    mcp: &mut TestAppServer,    params: ThreadSettingsUpdateParams,) -> Result<()> {    let request_id = mcp.send_thread_settings_update_request(params).await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let _: ThreadSettingsUpdateResponse = to_response(response)?;    Ok(())}async fn start_text_turn(mcp: &mut TestAppServer, thread_id: String) -> Result<()> {    let turn_request_id = mcp        .send_turn_start_request(TurnStartParams {            thread_id,            input: vec![V2UserInput::Text {                text: "hello".to_string(),                text_elements: Vec::new(),            }],            ..Default::default()        })        .await?;    let turn_response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),    )    .await??;    let TurnStartResponse { turn } = to_response(turn_response)?;    assert!(!turn.id.is_empty());    Ok(())}async fn start_thread(mcp: &mut TestAppServer) -> Result<ThreadStartResponse> {    let request_id = mcp        .send_thread_start_request(ThreadStartParams {            model: Some("mock-model".to_string()),            ..Default::default()        })        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    to_response(response)}async fn read_thread_with_turns(    mcp: &mut TestAppServer,    thread_id: &str,) -> Result<ThreadReadResponse> {    let request_id = mcp        .send_thread_read_request(ThreadReadParams {            thread_id: thread_id.to_string(),            include_turns: true,        })        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    to_response(response)}async fn read_thread_settings_updated(    mcp: &mut TestAppServer,) -> Result<ThreadSettingsUpdatedNotification> {    let notification: JSONRPCNotification = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_notification_message("thread/settings/updated"),    )    .await??;    let params = notification        .params        .context("thread/settings/updated should include params")?;    Ok(serde_json::from_value(params)?)}async fn received_response_bodies(server: &wiremock::MockServer) -> Result<Vec<Value>> {    let requests = server        .received_requests()        .await        .context("failed to fetch received requests")?;    let mut bodies = Vec::new();    for request in requests {        if request.url.path().ends_with("/responses") {            bodies.push(request.body_json::<Value>()?);        }    }    Ok(bodies)}fn service_tier_model_and_tier_id() -> Result<(String, String)> {    let model = all_model_presets()        .iter()        .find(|preset| preset.show_in_picker && !preset.service_tiers.is_empty())        .context("bundled model catalog should include a picker model with service tiers")?;    Ok((model.id.clone(), model.service_tiers[0].id.clone()))}fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {    write_mock_responses_config_toml(        codex_home,        server_uri,        &BTreeMap::default(),        /*auto_compact_limit*/ 200_000,        /*requires_openai_auth*/ None,        "mock_provider",        "compact",    )}