diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 5397e9b489..b40f3eca94 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index f9f0816894..c830a9642a 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -54,6 +54,7 @@ const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[ "ThreadBackgroundTerminal", "ThreadSearchOccurrence", "ThreadSearchTextRange", + "TurnSettingsUpdateStatus", ]; const SPECIAL_DEFINITIONS: &[&str] = &[ "ClientNotification", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 1309c92c03..d16e6ef05d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -973,6 +973,12 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::TurnStartResponse, }, + #[experimental("turn/settings/update")] + TurnSettingsUpdate => "turn/settings/update" { + params: v2::TurnSettingsUpdateParams, + serialization: thread_id(params.thread_id), + response: v2::TurnSettingsUpdateResponse, + }, TurnSteer => "turn/steer" { params: v2::TurnSteerParams, inspect_params: true, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index b92a1a2883..b1763095e7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -35,6 +35,54 @@ pub enum TurnStatus { } // Turn APIs +/// Experimental settings changes for one running turn, not future turns. +/// Unsupported fields are rejected rather than silently ignored. +/// Any live task kind may accept publication. Child sessions and consumers of +/// frozen initial settings are unchanged. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct TurnSettingsUpdateParams { + pub thread_id: String, + pub turn_id: String, + /// Omission or `null` leaves the model unchanged. + #[ts(optional = nullable)] + pub model: Option, + /// Omission or `null` leaves the effort unchanged. + #[ts(optional = nullable)] + pub effort: Option, + /// Omission or `null` leaves the summary preference unchanged. + #[ts(optional = nullable)] + pub summary: Option, + /// `null` clears the requested tier; omission leaves it unchanged. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSettingsUpdateResponse { + pub status: TurnSettingsUpdateStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum TurnSettingsUpdateStatus { + /// Published for subsequent captures, even if the values were unchanged. + /// Already captured steps are unchanged; a later inference is not guaranteed. + Applied, + /// No matching live task remained available for publication. + TargetUnavailable, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 68d2cb406c..5734d53446 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -211,6 +211,7 @@ Example with notification opt-out: - `thread/revert` — experimental. Replace a loaded paginated thread's durable history with the prefix strictly before `beforeTurnId` while preserving its thread id. The operation interrupts an active turn if needed, leaves older rollout files immutable, reloads the thread, returns updated thread metadata with empty `turns` plus pagination cursors, and emits `thread/reverted`. It does not revert local file changes. Parent-owned Multi-Agent V2 subagents reject direct revert requests. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` supplies the default roots for newly resolved environment selections. Explicit `environments[].runtimeWorkspaceRoots` override that fallback with environment-native absolute paths. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Deprecated experimental `multiAgentMode` is ignored; Ultra reasoning effort selects proactive behavior. Parent-owned Multi-Agent V2 subagents reject direct turns. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. Parent-owned Multi-Agent V2 subagents reject direct item injection. +- `turn/settings/update` — experimental; publish a narrow model-settings patch to the exact live task identified by `threadId` and `turnId`, regardless of task kind. Requires `step_model_switching`; returns `status: "applied"` or `status: "targetUnavailable"`, or a request error if rejected. Future-thread settings and already captured steps are unchanged. Parent-owned Multi-Agent V2 subagents reject direct settings updates. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. Parent-owned Multi-Agent V2 subagents reject direct steering. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. Also available for parent-owned Multi-Agent V2 subagents. - `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, pass `includeStartupContext: false` to omit Codex's generated startup context, and optionally pass `initialItems` to seed V3 with complete role-bearing text messages at session creation. Pass `realtimeStartInstructions` and `realtimeEndInstructions` to control the developer instructions given to the backing Codex model when this session starts and ends. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. For V3 automatic Codex text, `codexResponseHandoffMode` accepts `"thinking"` (the default; all output uses channel-less thinking appends), `"commentary"` (all output uses the commentary channel), or `"bemTags"` (the raw BEM envelope selects the API channel: BEM `analysis` and `commentary` use `commentary`, while BEM `final` and unparsable output use `speakable`). The BEM envelope remains in the appended text for the frontend model to interpret. V1 and V2 ignore this setting. For V3, pass `delegationAckFiller: false` to suppress the Realtime API's delegation acknowledgement filler or `true` to restore it; omitting the field preserves the Realtime API's default. V1 and V2 ignore `delegationAckFiller`. V3 handoffs do not prepend the legacy `"Agent Final Message"` label. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC. Parent-owned Multi-Agent V2 subagents reject this request. @@ -1238,6 +1239,46 @@ Use `thread/backgroundTerminals/terminate` to terminate one running background t { "id": 37, "result": { "terminated": true } } ``` +### Example: Update a running turn's settings (experimental) + +Enable `capabilities.experimentalApi` and the disabled-by-default `step_model_switching` +feature. Supply the exact turn ID from `turn/start`, `turn/started`, `thread/read` with +`includeTurns: true`, or `thread/turns/list`: + +```json +{ "method": "turn/settings/update", "id": 42, "params": { + "threadId": "thr_123", "turnId": "turn_456", "model": "gpt-5.4" +} } +{ "id": 42, "result": { "status": "applied" } } +``` + +Only `model`, `effort`, `summary`, and `serviceTier` may change. Unknown fields are +rejected. Omitted fields leave settings unchanged; `serviceTier: null` clears the +requested tier, while `null` for model, effort, or summary leaves it unchanged. + +The response waits for core: `status: "applied"` means a settings snapshot was published +for subsequent captures, even if its values were unchanged. Normal defaults and tier +filtering still apply; publication does not guarantee another inference will run or use +every preference. Existing captured steps keep their settings. + +Any live task kind may accept publication. Updating a parent review context does not +update its child session; shell tasks do not sample, and unmigrated compaction consumers +may still use initial settings. + +`status: "targetUnavailable"` means the exact live task was absent or lost before +publication. Validation, feature, and safety rejections return a JSON-RPC request error +with the explanation in `error.message`. Neither case retries or retargets another turn. + +This never updates future-thread settings. To change those too, send a separate +`thread/settings/update` and handle its queued acknowledgement separately. An older +server rejects the unknown turn method; clients must not fall back to a thread update. +No new step-state inspection API is provided. + +This diagnostic path retains live authorization and temporary safety checks. Most +consumers, including model-specific world-state instructions, still use initial-turn +settings. Saved threads are supported, but complete model-instruction correctness, +model attribution, and resume behavior for these switches are not guaranteed. + ### Example: Steer an active turn Use `turn/steer` to append additional user input to the currently active regular turn. This does diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index f9c6e24b87..7bc54ca0dc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1455,6 +1455,11 @@ impl MessageProcessor { ClientRequest::TurnSteer { params, .. } => { self.turn_processor.turn_steer(&request_id, params).await } + ClientRequest::TurnSettingsUpdate { params, .. } => { + self.turn_processor + .turn_settings_update(&request_id, params) + .await + } ClientRequest::TurnInterrupt { params, .. } => { self.turn_processor .turn_interrupt(&request_id, params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 9c0f6b7567..b719b6a0b2 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -300,6 +300,9 @@ use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnSettingsUpdateParams; +use codex_app_server_protocol::TurnSettingsUpdateResponse; +use codex_app_server_protocol::TurnSettingsUpdateStatus; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index a300d5a43a..d199d79b26 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -8,6 +8,8 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; +use codex_protocol::protocol::TurnSettingsUpdate; +use codex_protocol::protocol::TurnSettingsUpdateOutcome; use codex_skills::system_cache_root_dir; use crate::image_url::REMOTE_IMAGE_URL_ERROR; @@ -197,6 +199,45 @@ impl TurnRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn turn_settings_update( + &self, + request_id: &ConnectionRequestId, + params: TurnSettingsUpdateParams, + ) -> Result, JSONRPCErrorError> { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + self.ensure_direct_input_allowed(request_id, thread.as_ref()) + .await?; + let (reply, outcome) = oneshot::channel(); + self.submit_core_op( + request_id, + &thread, + Op::TurnSettings { + turn_id: params.turn_id, + update: TurnSettingsUpdate { + model: params.model, + // Match thread/settings/update: public null does not clear effort. + effort: params.effort.map(Some), + summary: params.summary, + service_tier: params.service_tier, + }, + reply, + }, + ) + .await + .map_err(|err| internal_error(format!("failed to submit turn settings: {err}")))?; + let outcome = outcome + .await + .map_err(|_| internal_error("turn settings operation ended before replying"))?; + let status = match outcome { + TurnSettingsUpdateOutcome::Applied => TurnSettingsUpdateStatus::Applied, + TurnSettingsUpdateOutcome::TargetUnavailable => { + TurnSettingsUpdateStatus::TargetUnavailable + } + TurnSettingsUpdateOutcome::Rejected { reason } => return Err(invalid_request(reason)), + }; + Ok(Some(TurnSettingsUpdateResponse { status }.into())) + } + pub(crate) async fn turn_steer( &self, request_id: &ConnectionRequestId, diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index b6ff5f97cc..aba370e4c2 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -1558,7 +1558,7 @@ impl TestAppServer { tokio::time::timeout(DEFAULT_REQUEST_TIMEOUT, self.read_response(request_id)).await? } - async fn send_request( + pub async fn send_request( &mut self, method: &str, params: Option, diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 2e8af60f05..70abf5a372 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -16,13 +16,13 @@ use codex_app_server_protocol::ThreadMemoryMode; use codex_app_server_protocol::ThreadMemoryModeSetParams; use codex_app_server_protocol::ThreadRealtimeStartParams; use codex_app_server_protocol::ThreadRealtimeStartTransport; -use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_protocol::protocol::RealtimeOutputModality; use pretty_assertions::assert_eq; use std::time::Duration; use tempfile::TempDir; +use test_case::test_case; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -161,8 +161,10 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result Ok(()) } +#[test_case("thread/settings/update"; "future settings")] +#[test_case("turn/settings/update"; "turn settings")] #[tokio::test] -async fn thread_settings_update_requires_experimental_api_capability() -> Result<()> { +async fn settings_update_requires_experimental_api_capability(method: &str) -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -186,18 +188,17 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result anyhow::bail!("expected initialize response, got {init:?}"); }; - let request_id = mcp - .send_thread_settings_update_request(ThreadSettingsUpdateParams { - thread_id: "thr_123".to_string(), - ..Default::default() - }) - .await?; + let mut params = serde_json::json!({"threadId": "thr_123"}); + if method == "turn/settings/update" { + params["turnId"] = serde_json::json!("turn_123"); + } + let request_id = mcp.send_request(method, Some(params)).await?; let error = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; - assert_experimental_capability_error(error, "thread/settings/update"); + assert_experimental_capability_error(error, method); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 5f9eaeff4c..07426518fb 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -114,6 +114,7 @@ mod thread_timeline; mod thread_unarchive; mod thread_unsubscribe; mod turn_interrupt; +mod turn_settings_update; mod turn_start; mod turn_start_zsh_fork; mod turn_steer; diff --git a/codex-rs/app-server/tests/suite/v2/turn_settings_update.rs b/codex-rs/app-server/tests/suite/v2/turn_settings_update.rs new file mode 100644 index 0000000000..24d1dc37d7 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/turn_settings_update.rs @@ -0,0 +1,465 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +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::TurnCompletedNotification; +use codex_app_server_protocol::TurnSettingsUpdateParams; +use codex_app_server_protocol::TurnSettingsUpdateResponse; +use codex_app_server_protocol::TurnSettingsUpdateStatus; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_features::Feature; +use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; +use test_case::test_case; +use tokio::time::timeout; + +const MODEL_A: &str = "step-settings-a"; +const MODEL_B: &str = "step-settings-b"; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Copy)] +enum UpdateScenario { + Future, + Current, + Rejected, +} + +#[test_case(UpdateScenario::Future; "future-only A A B")] +#[test_case(UpdateScenario::Current; "turn-only A B A")] +#[test_case(UpdateScenario::Rejected; "rejected turn update changes neither owner")] +#[tokio::test] +async fn settings_updates_report_results_and_preserve_the_target_on_saved_threads( + scenario: UpdateScenario, +) -> Result<()> { + let server = responses::start_mock_server().await; + let requests = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("response-1"), + responses::ev_function_call( + "pause-turn", + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after the settings update?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current turn." + }, { + "label": "No", + "description": "Stop the current turn." + }] + }] + }) + .to_string(), + ), + responses::ev_completed("response-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("response-2"), + responses::ev_assistant_message("message-2", "first turn done"), + responses::ev_completed("response-2"), + ]), + responses::sse(vec![ + responses::ev_response_created("response-3"), + responses::ev_assistant_message("message-3", "second turn done"), + responses::ev_completed("response-3"), + ]), + ], + ) + .await; + let codex_home = TempDir::new()?; + mock_config(codex_home.path(), &server.uri())? + .enable_feature(Feature::StepModelSwitching) + .write(codex_home.path())?; + if matches!(scenario, UpdateScenario::Rejected) { + let path = codex_home.path().join("step-models.json"); + let mut catalog: ModelsResponse = serde_json::from_slice(&std::fs::read(&path)?)?; + for model in &mut catalog.models { + model.node_repl_disabled = model.slug == MODEL_B; + } + std::fs::write(path, serde_json::to_vec(&catalog)?)?; + } + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + let request_id = app + .send_thread_start_request_with_auto_env(ThreadStartParams { + model: Some(MODEL_A.to_string()), + ..Default::default() + }) + .await?; + let ThreadStartResponse { thread, .. } = + timeout(DEFAULT_TIMEOUT, app.read_response(request_id)).await??; + assert!(!thread.ephemeral); + let turn_id = start_turn(&mut app, &thread.id).await?; + + let request = timeout(DEFAULT_TIMEOUT, app.read_stream_until_request_message()).await??; + let ServerRequest::ToolRequestUserInput { request_id, params } = request else { + anyhow::bail!("expected request_user_input, received {request:?}"); + }; + assert_eq!(params.thread_id, thread.id); + + let patch = TurnSettingsUpdateParams { + thread_id: thread.id.clone(), + turn_id: turn_id.clone(), + model: Some(MODEL_B.to_string()), + effort: Some(ReasoningEffort::High), + summary: Some(ReasoningSummary::Detailed), + service_tier: Some(Some("priority".to_string())), + }; + match scenario { + UpdateScenario::Future => { + let id = app + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: patch.model, + effort: patch.effort, + summary: patch.summary, + service_tier: patch.service_tier, + ..Default::default() + }) + .await?; + let _: ThreadSettingsUpdateResponse = + timeout(DEFAULT_TIMEOUT, app.read_response(id)).await??; + let updated: ThreadSettingsUpdatedNotification = timeout( + DEFAULT_TIMEOUT, + app.read_notification("thread/settings/updated"), + ) + .await??; + assert_eq!(updated.thread_settings.model, MODEL_B); + } + UpdateScenario::Current => { + let outcome: TurnSettingsUpdateResponse = app + .request(|request_id| ClientRequest::TurnSettingsUpdate { + request_id, + params: patch, + }) + .await?; + assert_eq!( + outcome, + TurnSettingsUpdateResponse { + status: TurnSettingsUpdateStatus::Applied, + } + ); + // Null keeps public effort/model/summary selections, but clears tier. + let id = app + .send_request( + "turn/settings/update", + Some(json!({ + "threadId": thread.id, "turnId": turn_id, + "model": null, "effort": null, "summary": null, "serviceTier": null, + })), + ) + .await?; + let outcome: Value = timeout(DEFAULT_TIMEOUT, app.read_response(id)).await??; + assert_eq!(outcome, json!({ "status": "applied" })); + } + UpdateScenario::Rejected => { + for field in [ + "personality", + "permissions", + "cwd", + "collaborationMode", + "scope", + ] { + let mut params = serde_json::to_value(&patch)?; + params[field] = json!("unsupported"); + let id = app + .send_request("turn/settings/update", Some(params)) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + app.read_stream_until_error_message(RequestId::Integer(id)), + ) + .await??; + assert!(error.error.message.contains("unknown field"), "{error:?}"); + assert!(error.error.message.contains(field), "{error:?}"); + } + let id = app + .send_request("turn/settings/update", Some(serde_json::to_value(patch)?)) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + app.read_stream_until_error_message(RequestId::Integer(id)), + ) + .await??; + assert_eq!( + error.error, + JSONRPCErrorError { + code: -32600, + message: + "the destination changes the admitted node REPL availability restriction" + .to_string(), + data: None, + } + ); + } + } + + app.send_response( + request_id, + json!({"answers": {"continue": {"answers": ["Yes (Recommended)"]}}}), + ) + .await?; + timeout( + DEFAULT_TIMEOUT, + app.read_stream_until_notification_message("turn/completed"), + ) + .await??; + start_turn(&mut app, &thread.id).await?; + timeout( + DEFAULT_TIMEOUT, + app.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let actual = requests + .requests() + .iter() + .map(|request| { + let body = request.body_json(); + json!({ + "model": body["model"], + "reasoning": body["reasoning"], + "service_tier": body.get("service_tier"), + }) + }) + .collect::>(); + let initial = json!({ + "model": MODEL_A, + "reasoning": { "effort": "low", "summary": "concise" }, + "service_tier": null, + }); + let changed = json!({ + "model": MODEL_B, + "reasoning": { "effort": "high", "summary": "detailed" }, + "service_tier": "priority", + }); + let expected = match scenario { + UpdateScenario::Future => vec![initial.clone(), initial, changed], + UpdateScenario::Current => { + let mut continued = changed; + continued["service_tier"] = Value::Null; + vec![initial.clone(), continued, initial] + } + UpdateScenario::Rejected => vec![initial.clone(), initial.clone(), initial], + }; + assert_eq!(actual, expected); + Ok(()) +} + +#[tokio::test] +async fn turn_settings_update_reports_unavailable_without_starting_or_changing_a_turn() -> Result<()> +{ + let server = responses::start_mock_server().await; + let requests = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("response-1"), + responses::ev_assistant_message("message-1", "done"), + responses::ev_completed("response-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + mock_config(codex_home.path(), &server.uri())? + .enable_feature(Feature::StepModelSwitching) + .write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + let request_id = app + .send_thread_start_request_with_auto_env(ThreadStartParams { + model: Some(MODEL_A.to_string()), + ..Default::default() + }) + .await?; + let ThreadStartResponse { thread, .. } = + timeout(DEFAULT_TIMEOUT, app.read_response(request_id)).await??; + assert!(!thread.ephemeral); + let update = TurnSettingsUpdateParams { + thread_id: thread.id.clone(), + turn_id: "never-started".to_string(), + model: Some(MODEL_B.to_string()), + ..Default::default() + }; + let outcome: TurnSettingsUpdateResponse = app + .request(|request_id| ClientRequest::TurnSettingsUpdate { + request_id, + params: update.clone(), + }) + .await?; + assert_eq!( + outcome, + TurnSettingsUpdateResponse { + status: TurnSettingsUpdateStatus::TargetUnavailable, + } + ); + assert!(requests.requests().is_empty()); + + let completed_turn_id = start_turn(&mut app, &thread.id).await?; + timeout( + DEFAULT_TIMEOUT, + app.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let outcome: TurnSettingsUpdateResponse = app + .request(|request_id| ClientRequest::TurnSettingsUpdate { + request_id, + params: TurnSettingsUpdateParams { + turn_id: completed_turn_id, + ..update + }, + }) + .await?; + assert_eq!( + outcome, + TurnSettingsUpdateResponse { + status: TurnSettingsUpdateStatus::TargetUnavailable, + } + ); + assert_eq!( + requests.single_request().body_json()["model"], + Value::String(MODEL_A.to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn disabled_turn_settings_update_reports_rejection_without_changing_future_settings() +-> Result<()> { + let server = responses::start_mock_server().await; + let requests = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("response-1"), + responses::ev_assistant_message("message-1", "done"), + responses::ev_completed("response-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + mock_config(codex_home.path(), &server.uri())?.write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + let request_id = app + .send_thread_start_request_with_auto_env(ThreadStartParams { + model: Some(MODEL_A.to_string()), + ..Default::default() + }) + .await?; + let ThreadStartResponse { thread, .. } = + timeout(DEFAULT_TIMEOUT, app.read_response(request_id)).await??; + + let id = app + .send_request( + "turn/settings/update", + Some(serde_json::to_value(TurnSettingsUpdateParams { + thread_id: thread.id.clone(), + turn_id: "idle".to_string(), + model: Some(MODEL_B.to_string()), + ..Default::default() + })?), + ) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + app.read_stream_until_error_message(RequestId::Integer(id)), + ) + .await??; + assert_eq!( + error.error, + JSONRPCErrorError { + code: -32600, + message: "turn settings updates require the step_model_switching feature".to_string(), + data: None, + } + ); + + start_turn(&mut app, &thread.id).await?; + let completed: TurnCompletedNotification = + timeout(DEFAULT_TIMEOUT, app.read_notification("turn/completed")).await??; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + assert_eq!(completed.turn.error, None); + assert_eq!( + requests.single_request().body_json()["model"], + Value::String(MODEL_A.to_string()) + ); + Ok(()) +} + +async fn start_turn(app: &mut TestAppServer, thread_id: &str) -> Result { + let request_id = app + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![UserInput::Text { + text: "continue".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let response: TurnStartResponse = + timeout(DEFAULT_TIMEOUT, app.read_response(request_id)).await??; + Ok(response.turn.id) +} + +fn mock_config(codex_home: &Path, server_uri: &str) -> Result { + let model = bundled_models_response()? + .models + .into_iter() + .find(|model| model.slug == "gpt-5.4") + .context("bundled catalog should include gpt-5.4")?; + let models = [MODEL_A, MODEL_B] + .into_iter() + .map(|slug| { + let mut model = model.clone(); + model.slug = slug.to_string(); + model + }) + .collect(); + let catalog_path = codex_home.join("step-models.json"); + std::fs::write( + &catalog_path, + serde_json::to_vec(&ModelsResponse { models })?, + )?; + let catalog_path = serde_json::to_string(&catalog_path)?; + Ok(MockResponsesConfig::new(server_uri) + .with_model(MODEL_A) + .with_root_config(&format!( + "model_catalog_json = {catalog_path}\nmodel_reasoning_effort = \"low\"\nmodel_reasoning_summary = \"concise\"" + )) + .with_provider_config("supports_websockets = false") + .enable_feature(Feature::DefaultModeRequestUserInput) + .enable_feature(Feature::FastMode)) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index d598d7befd..433be28752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -4106,6 +4106,10 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected( json!({"server": "unknown-server", "tool": "unknown-tool"}), ), ("thread/compact/start", json!({})), + ( + "turn/settings/update", + json!({"turnId": "any-child-turn", "model": "gpt-5.4"}), + ), ("thread/rollback", json!({"numTurns": 1})), ("thread/revert", json!({"beforeTurnId": "any-child-turn"})), (