mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## 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
413 lines
14 KiB
Rust
413 lines
14 KiB
Rust
use anyhow::Result;
|
|
use app_test_support::DEFAULT_CLIENT_NAME;
|
|
use app_test_support::MockResponsesConfig;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
|
use app_test_support::to_response;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_app_server_protocol::ClientInfo;
|
|
use codex_app_server_protocol::InitializeCapabilities;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::JSONRPCMessage;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::MockExperimentalMethodParams;
|
|
use codex_app_server_protocol::RequestId;
|
|
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::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);
|
|
|
|
#[tokio::test]
|
|
async fn mock_experimental_method_requires_experimental_api_capability() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build()
|
|
.await?;
|
|
|
|
let init = mcp
|
|
.initialize_with_capabilities(
|
|
default_client_info(),
|
|
Some(InitializeCapabilities {
|
|
experimental_api: false,
|
|
request_attestation: false,
|
|
opt_out_notification_methods: None,
|
|
mcp_server_openai_form_elicitation: false,
|
|
extensions: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
let JSONRPCMessage::Response(_) = init else {
|
|
anyhow::bail!("expected initialize response, got {init:?}");
|
|
};
|
|
|
|
let request_id = mcp
|
|
.send_mock_experimental_method_request(MockExperimentalMethodParams::default())
|
|
.await?;
|
|
let error = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_experimental_capability_error(error, "mock/experimentalMethod");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn realtime_conversation_start_requires_experimental_api_capability() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build()
|
|
.await?;
|
|
|
|
let init = mcp
|
|
.initialize_with_capabilities(
|
|
default_client_info(),
|
|
Some(InitializeCapabilities {
|
|
experimental_api: false,
|
|
request_attestation: false,
|
|
opt_out_notification_methods: None,
|
|
mcp_server_openai_form_elicitation: false,
|
|
extensions: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
let JSONRPCMessage::Response(_) = init else {
|
|
anyhow::bail!("expected initialize response, got {init:?}");
|
|
};
|
|
|
|
let request_id = mcp
|
|
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
|
client_managed_handoffs: None,
|
|
delegation_ack_filler: None,
|
|
flush_transcript_tail_on_session_end: None,
|
|
codex_responses_as_items: None,
|
|
codex_response_item_prefix: None,
|
|
codex_response_handoff_mode: None,
|
|
codex_response_handoff_channel_prefixes: None,
|
|
thread_id: "thr_123".to_string(),
|
|
model: None,
|
|
output_modality: RealtimeOutputModality::Audio,
|
|
include_startup_context: None,
|
|
initial_items: None,
|
|
realtime_start_instructions: None,
|
|
realtime_end_instructions: None,
|
|
prompt: Some(Some("hello".to_string())),
|
|
realtime_session_id: None,
|
|
transport: None,
|
|
version: None,
|
|
voice: None,
|
|
})
|
|
.await?;
|
|
let error = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_experimental_capability_error(error, "thread/realtime/start");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build()
|
|
.await?;
|
|
|
|
let init = mcp
|
|
.initialize_with_capabilities(
|
|
default_client_info(),
|
|
Some(InitializeCapabilities {
|
|
experimental_api: false,
|
|
request_attestation: false,
|
|
opt_out_notification_methods: None,
|
|
mcp_server_openai_form_elicitation: false,
|
|
extensions: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
let JSONRPCMessage::Response(_) = init else {
|
|
anyhow::bail!("expected initialize response, got {init:?}");
|
|
};
|
|
|
|
let request_id = mcp
|
|
.send_thread_memory_mode_set_request(ThreadMemoryModeSetParams {
|
|
thread_id: "thr_123".to_string(),
|
|
mode: ThreadMemoryMode::Disabled,
|
|
})
|
|
.await?;
|
|
let error = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_experimental_capability_error(error, "thread/memoryMode/set");
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case("thread/settings/update"; "future settings")]
|
|
#[test_case("turn/settings/update"; "turn settings")]
|
|
#[tokio::test]
|
|
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())
|
|
.without_auto_env()
|
|
.build()
|
|
.await?;
|
|
|
|
let init = mcp
|
|
.initialize_with_capabilities(
|
|
default_client_info(),
|
|
Some(InitializeCapabilities {
|
|
experimental_api: false,
|
|
request_attestation: false,
|
|
opt_out_notification_methods: None,
|
|
mcp_server_openai_form_elicitation: false,
|
|
extensions: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
let JSONRPCMessage::Response(_) = init else {
|
|
anyhow::bail!("expected initialize response, got {init:?}");
|
|
};
|
|
|
|
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, method);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build()
|
|
.await?;
|
|
|
|
let init = mcp
|
|
.initialize_with_capabilities(
|
|
default_client_info(),
|
|
Some(InitializeCapabilities {
|
|
experimental_api: false,
|
|
request_attestation: false,
|
|
opt_out_notification_methods: None,
|
|
mcp_server_openai_form_elicitation: false,
|
|
extensions: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
let JSONRPCMessage::Response(_) = init else {
|
|
anyhow::bail!("expected initialize response, got {init:?}");
|
|
};
|
|
|
|
let request_id = mcp
|
|
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
|
client_managed_handoffs: None,
|
|
delegation_ack_filler: None,
|
|
flush_transcript_tail_on_session_end: None,
|
|
codex_responses_as_items: None,
|
|
codex_response_item_prefix: None,
|
|
codex_response_handoff_mode: None,
|
|
codex_response_handoff_channel_prefixes: None,
|
|
thread_id: "thr_123".to_string(),
|
|
model: None,
|
|
output_modality: RealtimeOutputModality::Audio,
|
|
include_startup_context: None,
|
|
initial_items: None,
|
|
realtime_start_instructions: None,
|
|
realtime_end_instructions: None,
|
|
prompt: Some(Some("hello".to_string())),
|
|
realtime_session_id: None,
|
|
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
|
sdp: "v=offer\r\n".to_string(),
|
|
}),
|
|
version: None,
|
|
voice: None,
|
|
})
|
|
.await?;
|
|
let error = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_experimental_capability_error(error, "thread/realtime/start");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_start_mock_field_requires_experimental_api_capability() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
let init = mcp
|
|
.initialize_with_capabilities(
|
|
default_client_info(),
|
|
Some(InitializeCapabilities {
|
|
experimental_api: false,
|
|
request_attestation: false,
|
|
opt_out_notification_methods: None,
|
|
mcp_server_openai_form_elicitation: false,
|
|
extensions: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
let JSONRPCMessage::Response(_) = init else {
|
|
anyhow::bail!("expected initialize response, got {init:?}");
|
|
};
|
|
|
|
let request_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
mock_experimental_field: Some("mock".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
|
|
let error = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_experimental_capability_error(error, "thread/start.mockExperimentalField");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capability()
|
|
-> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
let init = mcp
|
|
.initialize_with_capabilities(
|
|
default_client_info(),
|
|
Some(InitializeCapabilities {
|
|
experimental_api: false,
|
|
request_attestation: false,
|
|
opt_out_notification_methods: None,
|
|
mcp_server_openai_form_elicitation: false,
|
|
extensions: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
let JSONRPCMessage::Response(_) = init else {
|
|
anyhow::bail!("expected initialize response, got {init:?}");
|
|
};
|
|
|
|
let request_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let _: ThreadStartResponse = to_response(response)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_start_granular_approval_policy_requires_experimental_api_capability() -> Result<()>
|
|
{
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
let init = mcp
|
|
.initialize_with_capabilities(
|
|
default_client_info(),
|
|
Some(InitializeCapabilities {
|
|
experimental_api: false,
|
|
request_attestation: false,
|
|
opt_out_notification_methods: None,
|
|
mcp_server_openai_form_elicitation: false,
|
|
extensions: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
let JSONRPCMessage::Response(_) = init else {
|
|
anyhow::bail!("expected initialize response, got {init:?}");
|
|
};
|
|
|
|
let request_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
approval_policy: Some(AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: true,
|
|
mcp_elicitations: false,
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
|
|
let error = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_experimental_capability_error(error, "askForApproval.granular");
|
|
Ok(())
|
|
}
|
|
|
|
fn default_client_info() -> ClientInfo {
|
|
ClientInfo {
|
|
name: DEFAULT_CLIENT_NAME.to_string(),
|
|
title: None,
|
|
version: "0.1.0".to_string(),
|
|
}
|
|
}
|
|
|
|
fn assert_experimental_capability_error(error: JSONRPCError, reason: &str) {
|
|
assert_eq!(error.error.code, -32600);
|
|
assert_eq!(
|
|
error.error.message,
|
|
format!("{reason} requires experimentalApi capability")
|
|
);
|
|
assert_eq!(error.error.data, None);
|
|
}
|