mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
## What changed - Expose an optional `AgentServiceTier` provider so host-owned children, including detached children, inherit the root's committed tier during spawn preparation, startup prewarm, and each inference step. - Reject per-turn tier overrides for children using the shared provider. Root updates apply to subsequent requests without changing requests already in flight. - Preserve independent configured tiers and role overrides when no provider is installed, including across eviction and reload. Spawned children inherit the parent's thread preference rather than its per-turn override. - Publish the latest committed root tier only after successful runtime registration, preventing failed duplicate starts from changing shared settings. ## Testing Add coverage for detached-child WebSocket prewarm and between-step tier changes, shared-child override rejection, independent-tier inheritance and reload, and settings updates before registration. GitOrigin-RevId: f0f3676813a1c59bd77baf95121e2d13e0163a8e
928 lines
32 KiB
Rust
928 lines
32 KiB
Rust
use super::agent_control::HostRuntime;
|
|
use anyhow::Result;
|
|
use codex_core::AgentRuntimeOwnership;
|
|
use codex_core::AgentServiceTier;
|
|
use codex_core::AgentTurnAdmission;
|
|
use codex_core::InheritedSessionId;
|
|
use codex_core::TurnInputRequest;
|
|
use codex_core::TurnStartOptions;
|
|
use codex_core::X_CODEX_ROUTING_HINT_HEADER;
|
|
use codex_features::Feature;
|
|
use codex_login::CodexAuth;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
|
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
|
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
|
use codex_protocol::error::CodexErrorDetails;
|
|
use codex_protocol::openai_models::ModelServiceTier;
|
|
use codex_protocol::openai_models::ToolMode;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
|
use codex_protocol::user_input::UserInput;
|
|
use core_test_support::responses::WebSocketConnectionConfig;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_exec_command_call;
|
|
use core_test_support::responses::ev_function_call;
|
|
use core_test_support::responses::ev_function_call_with_namespace;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::start_websocket_server;
|
|
use core_test_support::responses::start_websocket_server_with_headers;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::submit_thread_settings;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::wait_for_event;
|
|
use core_test_support::wait_for_successful_turn;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
|
|
|
|
#[test_case::test_case("priority", true, false; "fast mode enabled")]
|
|
#[test_case::test_case("priority", false, true; "fast mode disabled")]
|
|
#[test_case::test_case("ultrafast", false, true; "ultrafast mode enabled")]
|
|
#[test_case::test_case("ultrafast", true, false; "ultrafast mode disabled")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn spawned_agent_prewarm_handshake_inherits_effective_root_service_tier(
|
|
tier: &'static str,
|
|
fast_mode_enabled: bool,
|
|
ultrafast_mode_enabled: bool,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_websocket_server(vec![
|
|
vec![
|
|
vec![ev_response_created("root-warm"), ev_completed("root-warm")],
|
|
vec![
|
|
ev_response_created("root-spawn"),
|
|
ev_function_call_with_namespace(
|
|
"spawn-worker",
|
|
"collaboration",
|
|
"spawn_agent",
|
|
&json!({ "message": "hello", "task_name": "worker", "fork_turns": "none" })
|
|
.to_string(),
|
|
),
|
|
ev_completed("root-spawn"),
|
|
],
|
|
vec![ev_response_created("root-done"), ev_completed("root-done")],
|
|
],
|
|
vec![
|
|
vec![
|
|
ev_response_created("child-warm"),
|
|
ev_completed("child-warm"),
|
|
],
|
|
vec![
|
|
ev_response_created("child-done"),
|
|
ev_completed("child-done"),
|
|
],
|
|
],
|
|
])
|
|
.await;
|
|
let tier_enabled = if tier == "ultrafast" {
|
|
ultrafast_mode_enabled
|
|
} else {
|
|
fast_mode_enabled
|
|
};
|
|
let test = test_codex()
|
|
.with_model("gpt-5.4")
|
|
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
|
.with_model_info_override("gpt-5.4", move |model| {
|
|
model.service_tiers.push(ModelServiceTier {
|
|
id: tier.to_string(),
|
|
name: "Fast".to_string(),
|
|
description: "Priority processing".to_string(),
|
|
});
|
|
})
|
|
.with_config(move |config| {
|
|
for feature in [Feature::Collab, Feature::MultiAgentV2] {
|
|
config.features.enable(feature).expect("spawn feature");
|
|
}
|
|
if fast_mode_enabled {
|
|
config
|
|
.features
|
|
.enable(Feature::FastMode)
|
|
.expect("fast mode");
|
|
} else {
|
|
config
|
|
.features
|
|
.disable(Feature::FastMode)
|
|
.expect("fast mode");
|
|
}
|
|
config
|
|
.features
|
|
.set_enabled(Feature::UltrafastMode, ultrafast_mode_enabled)
|
|
.expect("configure Ultra Fast mode");
|
|
config.service_tier = Some(tier.to_string());
|
|
})
|
|
.build_with_websocket_server(&server)
|
|
.await?;
|
|
let mut created_threads = test.thread_manager.subscribe_thread_created();
|
|
tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0),
|
|
)
|
|
.await?;
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "spawn a worker".to_string(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
service_tier: Some(Some(tier.to_string())),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
let child_thread_id =
|
|
tokio::time::timeout(Duration::from_secs(5), created_threads.recv()).await??;
|
|
let child = test.thread_manager.get_thread(child_thread_id).await?;
|
|
|
|
assert!(
|
|
server
|
|
.wait_for_handshakes(/*expected*/ 2, Duration::from_secs(5))
|
|
.await,
|
|
"child startup should connect before its first turn"
|
|
);
|
|
let handshakes = server.handshakes();
|
|
let expected_hint = if tier_enabled {
|
|
format!("model=gpt-5.4;tier={tier}")
|
|
} else {
|
|
"model=gpt-5.4".to_string()
|
|
};
|
|
assert_eq!(
|
|
handshakes[1].header(X_CODEX_ROUTING_HINT_HEADER),
|
|
Some(expected_hint)
|
|
);
|
|
let child_warmup = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0),
|
|
)
|
|
.await?
|
|
.body_json();
|
|
assert_eq!(child_warmup["generate"], false);
|
|
assert_eq!(
|
|
child_warmup["service_tier"].as_str(),
|
|
tier_enabled.then_some(tier)
|
|
);
|
|
assert_eq!(server.handshakes().len(), 2);
|
|
child.shutdown_and_wait().await?;
|
|
test.codex.shutdown_and_wait().await?;
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct HostTier(Mutex<Option<String>>);
|
|
|
|
impl AgentServiceTier for HostTier {
|
|
fn current(&self) -> Option<String> {
|
|
self.0.lock().expect("host tier").clone()
|
|
}
|
|
|
|
fn update(&self, value: Option<String>) {
|
|
*self.0.lock().expect("host tier") = value;
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn detached_host_child_inherits_root_service_tier_at_prewarm_and_each_step() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_websocket_server(vec![
|
|
vec![vec![
|
|
ev_response_created("fixture-warm"),
|
|
ev_completed("fixture-warm"),
|
|
]],
|
|
vec![
|
|
vec![
|
|
ev_response_created("child-warm"),
|
|
ev_completed("child-warm"),
|
|
],
|
|
vec![
|
|
ev_response_created("child-tool"),
|
|
ev_function_call("hold", "hold_turn", "{}"),
|
|
ev_completed("child-tool"),
|
|
],
|
|
vec![
|
|
ev_response_created("child-done"),
|
|
ev_assistant_message("msg-done", "done"),
|
|
ev_completed("child-done"),
|
|
],
|
|
],
|
|
])
|
|
.await;
|
|
let test = test_codex()
|
|
.with_model("gpt-5.4")
|
|
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
|
.with_model_info_override("gpt-5.4", |model| {
|
|
model.service_tiers.push(ModelServiceTier {
|
|
id: "priority".into(),
|
|
name: "Fast".into(),
|
|
description: "Priority processing".into(),
|
|
});
|
|
})
|
|
.with_config(|config| {
|
|
config
|
|
.features
|
|
.enable(Feature::FastMode)
|
|
.expect("fast mode");
|
|
})
|
|
.build_with_websocket_server(&server)
|
|
.await?;
|
|
tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0),
|
|
)
|
|
.await?;
|
|
|
|
let tier = Arc::new(HostTier::default());
|
|
let mut options = test.start_thread_options().await;
|
|
options.config.service_tier = Some("priority".into());
|
|
// The host root only changes settings. Keep WebSocket traffic on the child.
|
|
options.config.model_provider.supports_websockets = false;
|
|
options
|
|
.thread_extension_init
|
|
.insert(AgentRuntimeOwnership::Host);
|
|
options
|
|
.thread_extension_init
|
|
.insert(Arc::new(HostRuntime) as Arc<dyn AgentTurnAdmission>);
|
|
options
|
|
.thread_extension_init
|
|
.insert(tier.clone() as Arc<dyn AgentServiceTier>);
|
|
let thread_extension_init = options.thread_extension_init.clone();
|
|
let root = test.thread_manager.start_thread(options).await?;
|
|
|
|
let mut options = test.start_thread_options().await;
|
|
options.session_source = Some(SessionSource::Custom("host".into()));
|
|
options.config.service_tier = None;
|
|
options.dynamic_tools = vec![DynamicToolSpec::Function(DynamicToolFunctionSpec {
|
|
name: "hold_turn".into(),
|
|
description: "Wait for the client to release this turn.".into(),
|
|
input_schema: json!({"type": "object", "properties": {}}),
|
|
defer_loading: false,
|
|
})];
|
|
options.thread_extension_init = thread_extension_init;
|
|
options
|
|
.thread_extension_init
|
|
.insert(InheritedSessionId(root.session_configured.session_id));
|
|
let child = test.thread_manager.start_thread(options).await?;
|
|
|
|
let warmup = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0),
|
|
)
|
|
.await?
|
|
.body_json();
|
|
assert_eq!(
|
|
server.handshakes()[1].header(X_CODEX_ROUTING_HINT_HEADER),
|
|
Some("model=gpt-5.4;tier=priority".into())
|
|
);
|
|
assert_eq!(warmup["generate"], false);
|
|
assert_eq!(warmup["service_tier"], "priority");
|
|
|
|
submit_thread_settings(
|
|
&child.thread,
|
|
ThreadSettingsOverrides {
|
|
service_tier: Some(None),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
assert_eq!(tier.current().as_deref(), Some("priority"));
|
|
|
|
let request = TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "Wait for the tier change.".into(),
|
|
text_elements: Vec::new(),
|
|
}]);
|
|
let error = child
|
|
.thread
|
|
.start_or_steer_turn(request.clone().on_start(TurnStartOptions {
|
|
service_tier: Some("default".into()),
|
|
..Default::default()
|
|
}))
|
|
.await
|
|
.expect_err("child uses the shared tier");
|
|
assert!(
|
|
matches!(error.details(), CodexErrorDetails::InvalidRequest(message) if message.contains("shared service tier")),
|
|
"{error:?}"
|
|
);
|
|
|
|
child.thread.start_or_steer_turn(request).await?;
|
|
wait_for_event(
|
|
&child.thread,
|
|
|event| matches!(event, EventMsg::DynamicToolCallRequest(call) if call.call_id == "hold"),
|
|
)
|
|
.await;
|
|
submit_thread_settings(
|
|
&root.thread,
|
|
ThreadSettingsOverrides {
|
|
service_tier: Some(Some("flex".into())),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
child
|
|
.thread
|
|
.submit(Op::DynamicToolResponse {
|
|
id: "hold".into(),
|
|
response: DynamicToolResponse {
|
|
content_items: vec![],
|
|
success: true,
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_successful_turn(&child.thread).await;
|
|
|
|
let connections = server.connections();
|
|
let [_, first, second] = connections[1].as_slice() else {
|
|
panic!("expected child warmup and two inference requests");
|
|
};
|
|
assert_eq!(
|
|
[
|
|
first.body_json()["service_tier"].clone(),
|
|
second.body_json()["service_tier"].clone(),
|
|
],
|
|
[json!("priority"), json!("flex")]
|
|
);
|
|
child.thread.shutdown_and_wait().await?;
|
|
root.thread.shutdown_and_wait().await?;
|
|
test.codex.shutdown_and_wait().await?;
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_model_info_override("gpt-5.2", |model_info| {
|
|
model_info.tool_mode = Some(ToolMode::CodeMode);
|
|
model_info.node_repl_auto_review_required = true;
|
|
})
|
|
.with_model_info_override("gpt-5.4", |model_info| {
|
|
model_info.use_responses_lite = true;
|
|
model_info.tool_mode = Some(ToolMode::CodeMode);
|
|
model_info.node_repl_disabled = true;
|
|
})
|
|
.with_model("gpt-5.2");
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
|
|
test.submit_turn("non-lite turn").await?;
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "lite turn".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 3);
|
|
let non_lite_turn = connection
|
|
.get(1)
|
|
.expect("missing non-lite turn request")
|
|
.body_json();
|
|
let lite_turn = connection
|
|
.get(2)
|
|
.expect("missing lite turn request")
|
|
.body_json();
|
|
|
|
assert_eq!(non_lite_turn["model"].as_str(), Some("gpt-5.2"));
|
|
assert_eq!(lite_turn["model"].as_str(), Some("gpt-5.4"));
|
|
for (request, auto_review_required, disabled) in
|
|
[(&non_lite_turn, true, false), (&lite_turn, false, true)]
|
|
{
|
|
let metadata: Value = serde_json::from_str(
|
|
request["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("websocket request should include turn metadata"),
|
|
)?;
|
|
assert_eq!(
|
|
metadata["node_repl_auto_review_required"],
|
|
auto_review_required
|
|
);
|
|
assert_eq!(metadata["node_repl_disabled"], disabled);
|
|
}
|
|
assert!(
|
|
non_lite_turn
|
|
.get("tools")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|tools| !tools.is_empty())
|
|
);
|
|
assert_eq!(lite_turn.get("previous_response_id"), None);
|
|
assert_eq!(lite_turn.get("tools"), None);
|
|
assert_eq!(lite_turn.get("instructions"), None);
|
|
let additional_tools = lite_turn
|
|
.get("input")
|
|
.and_then(Value::as_array)
|
|
.and_then(|input| input.first())
|
|
.filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools"))
|
|
.and_then(|item| item.get("tools"))
|
|
.and_then(Value::as_array)
|
|
.expect("lite turn should start with an additional_tools item");
|
|
assert!(!additional_tools.is_empty());
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn websocket_test_codex_shell_chain() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let call_id = "exec-command-call";
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_exec_command_call(call_id, "echo websocket"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg_1", "done"),
|
|
ev_completed("resp-2"),
|
|
],
|
|
]])
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_windows_cmd_shell();
|
|
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
test.submit_turn_with_policy("run the echo command", test.config.legacy_sandbox_policy())
|
|
.await?;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
|
|
let first_turn = connection
|
|
.first()
|
|
.expect("missing first turn request")
|
|
.body_json();
|
|
let second_turn = connection
|
|
.get(1)
|
|
.expect("missing second turn request")
|
|
.body_json();
|
|
|
|
assert_eq!(first_turn["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second_turn["type"].as_str(), Some("response.create"));
|
|
|
|
let input_items = second_turn
|
|
.get("input")
|
|
.and_then(Value::as_array)
|
|
.expect("second response.create input array");
|
|
assert!(!input_items.is_empty());
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case::test_case(false, false; "update_plan disabled")]
|
|
#[test_case::test_case(true, false; "update_plan enabled")]
|
|
#[test_case::test_case(true, true; "incremental tools")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn websocket_first_turn_uses_startup_prewarm_and_create(
|
|
update_plan_enabled: bool,
|
|
incremental_tools: bool,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg_1", "hello"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
]])
|
|
.await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_model("gpt-5.2")
|
|
.with_config(move |config| {
|
|
config.update_plan_enabled = update_plan_enabled;
|
|
config.analytics_enabled = Some(false);
|
|
if incremental_tools {
|
|
config
|
|
.features
|
|
.enable(Feature::IncrementalTools)
|
|
.expect("enable incremental tools");
|
|
}
|
|
});
|
|
if incremental_tools {
|
|
builder = builder.with_model_info_override("gpt-5.2", |model| {
|
|
model.use_responses_lite = true;
|
|
});
|
|
}
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
test.submit_turn_with_policy("hello", test.config.legacy_sandbox_policy())
|
|
.await?;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let warmup = connection
|
|
.first()
|
|
.expect("missing warmup request")
|
|
.body_json();
|
|
let turn = connection.get(1).expect("missing turn request").body_json();
|
|
assert_eq!(turn["previous_response_id"], "warm-1");
|
|
if incremental_tools {
|
|
assert_eq!(warmup["input"], json!([]));
|
|
assert!(
|
|
turn["input"]
|
|
.as_array()
|
|
.expect("turn input array")
|
|
.iter()
|
|
.any(|item| item["type"] == "additional_tools")
|
|
);
|
|
} else {
|
|
assert_eq!(
|
|
warmup["input"][0]["content"][0]["text"]
|
|
.as_str()
|
|
.expect("warmup base instructions")
|
|
.contains("update_plan"),
|
|
update_plan_enabled
|
|
);
|
|
}
|
|
assert_eq!(warmup["type"].as_str(), Some("response.create"));
|
|
assert_eq!(warmup["generate"].as_bool(), Some(false));
|
|
let warmup_metadata: Value = serde_json::from_str(
|
|
warmup["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("warmup turn metadata"),
|
|
)?;
|
|
assert_eq!(warmup_metadata["request_kind"].as_str(), Some("prewarm"));
|
|
assert_eq!(warmup_metadata["analytics_enabled"].as_bool(), Some(false));
|
|
assert_eq!(
|
|
warmup_metadata["window_id"].as_str(),
|
|
warmup["client_metadata"]["x-codex-window-id"].as_str()
|
|
);
|
|
assert_eq!(
|
|
turn["tools"]
|
|
.as_array()
|
|
.is_some_and(|tools| !tools.is_empty()),
|
|
!incremental_tools
|
|
);
|
|
assert_eq!(turn["type"].as_str(), Some("response.create"));
|
|
assert_eq!(turn.get("generate"), None);
|
|
let turn_metadata: Value = serde_json::from_str(
|
|
turn["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("turn metadata"),
|
|
)?;
|
|
assert_eq!(turn_metadata["request_kind"].as_str(), Some("turn"));
|
|
assert_eq!(turn_metadata["analytics_enabled"].as_bool(), Some(false));
|
|
assert_eq!(warmup_metadata["window_number"].as_u64(), Some(0));
|
|
assert_eq!(
|
|
warmup_metadata["window_number"],
|
|
turn_metadata["window_number"]
|
|
);
|
|
assert!(warmup_metadata["context_window_id"].is_string());
|
|
assert_eq!(
|
|
warmup_metadata["context_window_id"],
|
|
turn_metadata["context_window_id"]
|
|
);
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn websocket_first_turn_handles_handshake_delay_with_startup_prewarm() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
|
|
requests: vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg_1", "hello"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
],
|
|
response_headers: Vec::new(),
|
|
// Delay handshake so turn processing must tolerate websocket startup latency.
|
|
accept_delay: Some(Duration::from_millis(150)),
|
|
close_after_requests: true,
|
|
}])
|
|
.await;
|
|
|
|
let mut builder = test_codex();
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
test.submit_turn_with_policy("hello", test.config.legacy_sandbox_policy())
|
|
.await?;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let warmup = connection
|
|
.first()
|
|
.expect("missing warmup request")
|
|
.body_json();
|
|
let turn = connection.get(1).expect("missing turn request").body_json();
|
|
assert_eq!(warmup["type"].as_str(), Some("response.create"));
|
|
assert_eq!(warmup["generate"].as_bool(), Some(false));
|
|
assert!(
|
|
turn["tools"]
|
|
.as_array()
|
|
.is_some_and(|tools| !tools.is_empty()),
|
|
"expected request tools to be populated"
|
|
);
|
|
assert_eq!(turn["type"].as_str(), Some("response.create"));
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn websocket_v2_test_codex_shell_chain() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let call_id = "exec-command-call";
|
|
let mut exec_command_call = ev_exec_command_call(call_id, "echo websocket");
|
|
exec_command_call["item"]["id"] = serde_json::json!("fc_exec_command_call");
|
|
exec_command_call["item"]["internal_chat_message_metadata_passthrough"] =
|
|
serde_json::json!({"turn_id": "turn-123"});
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
exec_command_call,
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg_1", "done"),
|
|
ev_completed("resp-2"),
|
|
],
|
|
]])
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_windows_cmd_shell().with_config(|config| {
|
|
config
|
|
.features
|
|
.enable(Feature::ResponsesWebsocketsV2)
|
|
.expect("test config should allow feature update");
|
|
});
|
|
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
test.submit_turn_with_policy("run the echo command", test.config.legacy_sandbox_policy())
|
|
.await?;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 3);
|
|
|
|
let warmup = connection
|
|
.first()
|
|
.expect("missing warmup request")
|
|
.body_json();
|
|
let first_turn = connection
|
|
.get(1)
|
|
.expect("missing first turn request")
|
|
.body_json();
|
|
let second_turn = connection
|
|
.get(2)
|
|
.expect("missing second turn request")
|
|
.body_json();
|
|
|
|
assert_eq!(warmup["type"].as_str(), Some("response.create"));
|
|
assert_eq!(warmup["generate"].as_bool(), Some(false));
|
|
assert_eq!(first_turn["type"].as_str(), Some("response.create"));
|
|
assert_eq!(first_turn["previous_response_id"].as_str(), Some("warm-1"));
|
|
assert!(
|
|
first_turn
|
|
.get("input")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|items| !items.is_empty())
|
|
);
|
|
assert_eq!(second_turn["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second_turn["previous_response_id"].as_str(), Some("resp-1"));
|
|
|
|
let create_items = second_turn
|
|
.get("input")
|
|
.and_then(Value::as_array)
|
|
.expect("response.create input array");
|
|
assert!(!create_items.is_empty());
|
|
|
|
let output_item = create_items
|
|
.iter()
|
|
.find(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output"))
|
|
.expect("function_call_output in create");
|
|
assert_eq!(
|
|
output_item.get("call_id").and_then(Value::as_str),
|
|
Some(call_id)
|
|
);
|
|
|
|
let handshake = server.single_handshake();
|
|
assert_eq!(
|
|
handshake.header("openai-beta"),
|
|
Some(WS_V2_BETA_HEADER_VALUE.to_string())
|
|
);
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case::test_case(None, Some("ultrafast"); "standard to ultrafast")]
|
|
#[test_case::test_case(Some("ultrafast"), None; "ultrafast to standard")]
|
|
#[test_case::test_case(Some("priority"), Some("ultrafast"); "priority to ultrafast")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn websocket_v2_first_turn_sends_full_create_when_tier_changes_after_startup_prewarm(
|
|
startup_tier: Option<&'static str>,
|
|
turn_tier: Option<&'static str>,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let warmup_turn_state = "startup-turn-state";
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("warm-1"),
|
|
json!({
|
|
"type": "response.metadata",
|
|
"headers": {"x-codex-turn-state": warmup_turn_state},
|
|
}),
|
|
ev_completed("warm-1"),
|
|
],
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg_1", "done"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
]])
|
|
.await;
|
|
|
|
let model = "gpt-5.6-sol";
|
|
let mut builder = test_codex()
|
|
.with_model_info_override(model, |model_info| {
|
|
// Exercise tier transitions independently of the bundled model catalog.
|
|
model_info.service_tiers = ["priority", "ultrafast"]
|
|
.into_iter()
|
|
.map(|tier| ModelServiceTier {
|
|
id: tier.to_string(),
|
|
name: tier.to_string(),
|
|
description: String::new(),
|
|
})
|
|
.collect();
|
|
})
|
|
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
|
.with_config(move |config| {
|
|
for feature in [Feature::ResponsesWebsocketsV2, Feature::FastMode] {
|
|
config
|
|
.features
|
|
.enable(feature)
|
|
.expect("test config should allow feature update");
|
|
}
|
|
config.service_tier = startup_tier.map(str::to_string);
|
|
});
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
|
|
let warmup = server
|
|
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0)
|
|
.await
|
|
.body_json();
|
|
assert_eq!(warmup["type"].as_str(), Some("response.create"));
|
|
assert_eq!(warmup["generate"].as_bool(), Some(false));
|
|
assert_eq!(warmup["service_tier"].as_str(), startup_tier);
|
|
|
|
test.submit_turn_with_service_tier("hello", turn_tier)
|
|
.await?;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(
|
|
server
|
|
.single_handshake()
|
|
.header(X_CODEX_ROUTING_HINT_HEADER),
|
|
Some(match startup_tier {
|
|
Some(tier) => format!("model={model};tier={tier}"),
|
|
None => format!("model={model}"),
|
|
})
|
|
);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let first_turn = connection
|
|
.get(1)
|
|
.expect("missing first turn request")
|
|
.body_json();
|
|
|
|
assert_eq!(first_turn["type"].as_str(), Some("response.create"));
|
|
assert_eq!(first_turn["model"].as_str(), Some(model));
|
|
assert_eq!(first_turn["service_tier"].as_str(), turn_tier);
|
|
assert_ne!(first_turn["generate"].as_bool(), Some(false));
|
|
assert_eq!(
|
|
first_turn["client_metadata"]["x-codex-turn-state"].as_str(),
|
|
Some(warmup_turn_state)
|
|
);
|
|
assert_eq!(first_turn.get("previous_response_id"), None);
|
|
assert!(
|
|
first_turn
|
|
.get("input")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|items| !items.is_empty())
|
|
);
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn websocket_v2_next_turn_uses_updated_service_tier() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg_1", "fast"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg_2", "standard"),
|
|
ev_completed("resp-2"),
|
|
],
|
|
]])
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config
|
|
.features
|
|
.enable(Feature::ResponsesWebsocketsV2)
|
|
.expect("test config should allow feature update");
|
|
});
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
|
|
let warmup = server
|
|
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0)
|
|
.await
|
|
.body_json();
|
|
assert_eq!(warmup["type"].as_str(), Some("response.create"));
|
|
assert_eq!(warmup["generate"].as_bool(), Some(false));
|
|
assert_eq!(warmup.get("service_tier"), None);
|
|
|
|
test.submit_turn_with_service_tier("first", Some(ServiceTier::Fast.request_value()))
|
|
.await?;
|
|
test.submit_turn_with_service_tier("second", /*service_tier*/ None)
|
|
.await?;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 3);
|
|
|
|
let first_turn = connection
|
|
.get(1)
|
|
.expect("missing first turn request")
|
|
.body_json();
|
|
let second_turn = connection
|
|
.get(2)
|
|
.expect("missing second turn request")
|
|
.body_json();
|
|
|
|
assert_eq!(first_turn["type"].as_str(), Some("response.create"));
|
|
assert_eq!(first_turn["service_tier"].as_str(), Some("priority"));
|
|
assert_eq!(first_turn.get("previous_response_id"), None);
|
|
assert!(
|
|
first_turn
|
|
.get("input")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|items| !items.is_empty())
|
|
);
|
|
|
|
assert_eq!(second_turn["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second_turn.get("service_tier"), None);
|
|
assert_eq!(second_turn.get("previous_response_id"), None);
|
|
assert!(
|
|
second_turn
|
|
.get("input")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|items| !items.is_empty())
|
|
);
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|