mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
Add turn-scoped settings updates to the app server (#40656)
## What changed - Add the experimental `turn/settings/update` method for changing `model`, `effort`, `summary`, and `serviceTier` on a specific live turn without changing future turns. - Report whether the update was applied or the target turn was unavailable, and reject unsupported fields, disabled feature use, unsafe model changes, and direct updates to parent-owned subagents. - Document the method's feature requirements, null handling, and limits. ## Testing - Cover current-turn and future-turn isolation, rejected and unavailable targets, feature and capability gates, saved threads, and subagent ownership. GitOrigin-RevId: 18440f6d7ee5fcd06ae23bd41b22c492b62bb2b5
This commit is contained in:
1 parent
a10c8127f7
commit
9695e71519
13 files changed
+626
-10
No files matched your search
BIN
Binary file not shown.
@@ -54,6 +54,7 @@ const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
|
|||||||
"ThreadBackgroundTerminal",
|
"ThreadBackgroundTerminal",
|
||||||
"ThreadSearchOccurrence",
|
"ThreadSearchOccurrence",
|
||||||
"ThreadSearchTextRange",
|
"ThreadSearchTextRange",
|
||||||
|
"TurnSettingsUpdateStatus",
|
||||||
];
|
];
|
||||||
const SPECIAL_DEFINITIONS: &[&str] = &[
|
const SPECIAL_DEFINITIONS: &[&str] = &[
|
||||||
"ClientNotification",
|
"ClientNotification",
|
||||||
|
|||||||
@@ -973,6 +973,12 @@ client_request_definitions! {
|
|||||||
serialization: thread_id(params.thread_id),
|
serialization: thread_id(params.thread_id),
|
||||||
response: v2::TurnStartResponse,
|
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" {
|
TurnSteer => "turn/steer" {
|
||||||
params: v2::TurnSteerParams,
|
params: v2::TurnSteerParams,
|
||||||
inspect_params: true,
|
inspect_params: true,
|
||||||
|
|||||||
@@ -35,6 +35,54 @@ pub enum TurnStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Turn APIs
|
// 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<String>,
|
||||||
|
/// Omission or `null` leaves the effort unchanged.
|
||||||
|
#[ts(optional = nullable)]
|
||||||
|
pub effort: Option<ReasoningEffort>,
|
||||||
|
/// Omission or `null` leaves the summary preference unchanged.
|
||||||
|
#[ts(optional = nullable)]
|
||||||
|
pub summary: Option<ReasoningSummary>,
|
||||||
|
/// `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<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export_to = "v2/")]
|
#[ts(export_to = "v2/")]
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `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.
|
- `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/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.
|
- `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.
|
- `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 } }
|
{ "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
|
### Example: Steer an active turn
|
||||||
|
|
||||||
Use `turn/steer` to append additional user input to the currently active regular turn. This does
|
Use `turn/steer` to append additional user input to the currently active regular turn. This does
|
||||||
|
|||||||
@@ -1455,6 +1455,11 @@ impl MessageProcessor {
|
|||||||
ClientRequest::TurnSteer { params, .. } => {
|
ClientRequest::TurnSteer { params, .. } => {
|
||||||
self.turn_processor.turn_steer(&request_id, params).await
|
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, .. } => {
|
ClientRequest::TurnInterrupt { params, .. } => {
|
||||||
self.turn_processor
|
self.turn_processor
|
||||||
.turn_interrupt(&request_id, params)
|
.turn_interrupt(&request_id, params)
|
||||||
|
|||||||
@@ -300,6 +300,9 @@ use codex_app_server_protocol::TurnError;
|
|||||||
use codex_app_server_protocol::TurnInterruptParams;
|
use codex_app_server_protocol::TurnInterruptParams;
|
||||||
use codex_app_server_protocol::TurnInterruptResponse;
|
use codex_app_server_protocol::TurnInterruptResponse;
|
||||||
use codex_app_server_protocol::TurnItemsView;
|
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::TurnStartParams;
|
||||||
use codex_app_server_protocol::TurnStartResponse;
|
use codex_app_server_protocol::TurnStartResponse;
|
||||||
use codex_app_server_protocol::TurnStatus;
|
use codex_app_server_protocol::TurnStatus;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use codex_protocol::models::ContentItem;
|
|||||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||||
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
|
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
|
||||||
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
|
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 codex_skills::system_cache_root_dir;
|
||||||
|
|
||||||
use crate::image_url::REMOTE_IMAGE_URL_ERROR;
|
use crate::image_url::REMOTE_IMAGE_URL_ERROR;
|
||||||
@@ -197,6 +199,45 @@ impl TurnRequestProcessor {
|
|||||||
.map(|response| Some(response.into()))
|
.map(|response| Some(response.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn turn_settings_update(
|
||||||
|
&self,
|
||||||
|
request_id: &ConnectionRequestId,
|
||||||
|
params: TurnSettingsUpdateParams,
|
||||||
|
) -> Result<Option<ClientResponsePayload>, 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(
|
pub(crate) async fn turn_steer(
|
||||||
&self,
|
&self,
|
||||||
request_id: &ConnectionRequestId,
|
request_id: &ConnectionRequestId,
|
||||||
|
|||||||
@@ -1558,7 +1558,7 @@ impl TestAppServer {
|
|||||||
tokio::time::timeout(DEFAULT_REQUEST_TIMEOUT, self.read_response(request_id)).await?
|
tokio::time::timeout(DEFAULT_REQUEST_TIMEOUT, self.read_response(request_id)).await?
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_request(
|
pub async fn send_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
method: &str,
|
method: &str,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ use codex_app_server_protocol::ThreadMemoryMode;
|
|||||||
use codex_app_server_protocol::ThreadMemoryModeSetParams;
|
use codex_app_server_protocol::ThreadMemoryModeSetParams;
|
||||||
use codex_app_server_protocol::ThreadRealtimeStartParams;
|
use codex_app_server_protocol::ThreadRealtimeStartParams;
|
||||||
use codex_app_server_protocol::ThreadRealtimeStartTransport;
|
use codex_app_server_protocol::ThreadRealtimeStartTransport;
|
||||||
use codex_app_server_protocol::ThreadSettingsUpdateParams;
|
|
||||||
use codex_app_server_protocol::ThreadStartParams;
|
use codex_app_server_protocol::ThreadStartParams;
|
||||||
use codex_app_server_protocol::ThreadStartResponse;
|
use codex_app_server_protocol::ThreadStartResponse;
|
||||||
use codex_protocol::protocol::RealtimeOutputModality;
|
use codex_protocol::protocol::RealtimeOutputModality;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
use test_case::test_case;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
@@ -161,8 +161,10 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case("thread/settings/update"; "future settings")]
|
||||||
|
#[test_case("turn/settings/update"; "turn settings")]
|
||||||
#[tokio::test]
|
#[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 codex_home = TempDir::new()?;
|
||||||
let mut mcp = TestAppServer::builder()
|
let mut mcp = TestAppServer::builder()
|
||||||
.with_codex_home(codex_home.path())
|
.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:?}");
|
anyhow::bail!("expected initialize response, got {init:?}");
|
||||||
};
|
};
|
||||||
|
|
||||||
let request_id = mcp
|
let mut params = serde_json::json!({"threadId": "thr_123"});
|
||||||
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
|
if method == "turn/settings/update" {
|
||||||
thread_id: "thr_123".to_string(),
|
params["turnId"] = serde_json::json!("turn_123");
|
||||||
..Default::default()
|
}
|
||||||
})
|
let request_id = mcp.send_request(method, Some(params)).await?;
|
||||||
.await?;
|
|
||||||
let error = timeout(
|
let error = timeout(
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||||
)
|
)
|
||||||
.await??;
|
.await??;
|
||||||
assert_experimental_capability_error(error, "thread/settings/update");
|
assert_experimental_capability_error(error, method);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ mod thread_timeline;
|
|||||||
mod thread_unarchive;
|
mod thread_unarchive;
|
||||||
mod thread_unsubscribe;
|
mod thread_unsubscribe;
|
||||||
mod turn_interrupt;
|
mod turn_interrupt;
|
||||||
|
mod turn_settings_update;
|
||||||
mod turn_start;
|
mod turn_start;
|
||||||
mod turn_start_zsh_fork;
|
mod turn_start_zsh_fork;
|
||||||
mod turn_steer;
|
mod turn_steer;
|
||||||
|
|||||||
@@ -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::<Vec<_>>();
|
||||||
|
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<String> {
|
||||||
|
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<MockResponsesConfig> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -4106,6 +4106,10 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected(
|
|||||||
json!({"server": "unknown-server", "tool": "unknown-tool"}),
|
json!({"server": "unknown-server", "tool": "unknown-tool"}),
|
||||||
),
|
),
|
||||||
("thread/compact/start", json!({})),
|
("thread/compact/start", json!({})),
|
||||||
|
(
|
||||||
|
"turn/settings/update",
|
||||||
|
json!({"turnId": "any-child-turn", "model": "gpt-5.4"}),
|
||||||
|
),
|
||||||
("thread/rollback", json!({"numTurns": 1})),
|
("thread/rollback", json!({"numTurns": 1})),
|
||||||
("thread/revert", json!({"beforeTurnId": "any-child-turn"})),
|
("thread/revert", json!({"beforeTurnId": "any-child-turn"})),
|
||||||
(
|
(
|
||||||
|
|||||||
Reference in new issue
Block a user