mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
478 lines
16 KiB
Rust
478 lines
16 KiB
Rust
use anyhow::Result;
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::create_final_assistant_message_sse_response;
|
|
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
|
use app_test_support::to_response;
|
|
use codex_app_server_protocol::ClientInfo;
|
|
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
|
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
|
|
use codex_app_server_protocol::InitializeCapabilities;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::QueuedTurnStatus;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ThreadQueueAddParams;
|
|
use codex_app_server_protocol::ThreadQueueAddResponse;
|
|
use codex_app_server_protocol::ThreadQueueChangedNotification;
|
|
use codex_app_server_protocol::ThreadQueueDeleteParams;
|
|
use codex_app_server_protocol::ThreadQueueDeleteResponse;
|
|
use codex_app_server_protocol::ThreadQueueListParams;
|
|
use codex_app_server_protocol::ThreadQueueListResponse;
|
|
use codex_app_server_protocol::ThreadQueueReorderParams;
|
|
use codex_app_server_protocol::ThreadQueueReorderResponse;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::TurnSubmission;
|
|
use codex_app_server_protocol::UserInput as V2UserInput;
|
|
use std::collections::BTreeMap;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
|
|
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
|
|
|
#[tokio::test]
|
|
async fn queue_add_persists_turn_params_and_emits_snapshot() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
create_final_assistant_message_sse_response("unused")?,
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
write_queue_test_config(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
initialize_experimental(&mut mcp).await?;
|
|
let thread = start_thread(&mut mcp).await?;
|
|
|
|
let add_request_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/add",
|
|
Some(serde_json::to_value(ThreadQueueAddParams {
|
|
thread_id: thread.id.clone(),
|
|
submission: text_submission("queued serialized input").into(),
|
|
})?),
|
|
)
|
|
.await?;
|
|
let add_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(add_request_id)),
|
|
)
|
|
.await??;
|
|
let ThreadQueueAddResponse { queued_turn } = to_response(add_response)?;
|
|
assert!(matches!(queued_turn.status, QueuedTurnStatus::Pending));
|
|
|
|
let notification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/queue/changed"),
|
|
)
|
|
.await??;
|
|
let notification: ThreadQueueChangedNotification =
|
|
serde_json::from_value(notification.params.expect("thread/queue/changed params"))?;
|
|
assert_eq!(notification.thread_id, thread.id);
|
|
assert_eq!(notification.queued_turns, vec![queued_turn.clone()]);
|
|
|
|
let ThreadQueueListResponse { data, next_cursor } = list_queue_page(
|
|
&mut mcp, &thread.id, /*cursor*/ None, /*limit*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(data, vec![queued_turn]);
|
|
assert_eq!(next_cursor, None);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_add_rejects_ephemeral_threads() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
create_final_assistant_message_sse_response("unused")?,
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
write_queue_test_config(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
initialize_experimental(&mut mcp).await?;
|
|
let start_request_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
ephemeral: Some(true),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let start_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response(start_response)?;
|
|
|
|
let add_request_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/add",
|
|
Some(serde_json::to_value(ThreadQueueAddParams {
|
|
thread_id: thread.id.clone(),
|
|
submission: text_submission("ephemeral queued turn").into(),
|
|
})?),
|
|
)
|
|
.await?;
|
|
let add_error: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(add_request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(add_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
|
assert_eq!(
|
|
add_error.error.message,
|
|
format!(
|
|
"ephemeral thread does not support queued turns: {}",
|
|
thread.id
|
|
)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_add_rejects_requests_when_feature_is_disabled() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
create_final_assistant_message_sse_response("unused")?,
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
write_queue_test_config_without_feature(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
initialize_experimental(&mut mcp).await?;
|
|
let thread = start_thread(&mut mcp).await?;
|
|
|
|
let add_request_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/add",
|
|
Some(serde_json::to_value(ThreadQueueAddParams {
|
|
thread_id: thread.id,
|
|
submission: text_submission("disabled queued turn").into(),
|
|
})?),
|
|
)
|
|
.await?;
|
|
let add_error: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(add_request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(add_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
|
assert_eq!(
|
|
add_error.error.message,
|
|
"app-server queue feature is disabled"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_feature_enablement_controls_queue_access_without_deleting_rows() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
create_final_assistant_message_sse_response("unused")?,
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
write_queue_test_config_without_feature(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
initialize_experimental(&mut mcp).await?;
|
|
let thread = start_thread(&mut mcp).await?;
|
|
|
|
set_queue_feature(&mut mcp, /*enabled*/ true).await?;
|
|
let queued_turn_id = queue_turn(&mut mcp, &thread.id, "durable queued turn").await?;
|
|
|
|
set_queue_feature(&mut mcp, /*enabled*/ false).await?;
|
|
let list_request_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/list",
|
|
Some(serde_json::to_value(ThreadQueueListParams {
|
|
thread_id: thread.id.clone(),
|
|
cursor: None,
|
|
limit: None,
|
|
})?),
|
|
)
|
|
.await?;
|
|
let list_error: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(list_request_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(list_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
|
assert_eq!(
|
|
list_error.error.message,
|
|
"app-server queue feature is disabled"
|
|
);
|
|
|
|
set_queue_feature(&mut mcp, /*enabled*/ true).await?;
|
|
assert_eq!(
|
|
list_queue_ids(&mut mcp, &thread.id).await?,
|
|
vec![queued_turn_id]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn visible_queue_rows_support_pagination_reorder_and_delete() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
create_final_assistant_message_sse_response("unused")?,
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
write_queue_test_config(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
initialize_experimental(&mut mcp).await?;
|
|
let thread = start_thread(&mut mcp).await?;
|
|
|
|
let first = queue_turn(&mut mcp, &thread.id, "first queued").await?;
|
|
let second = queue_turn(&mut mcp, &thread.id, "second queued").await?;
|
|
assert_eq!(
|
|
list_queue_ids(&mut mcp, &thread.id).await?,
|
|
vec![first.clone(), second.clone()]
|
|
);
|
|
|
|
let first_page = list_queue_page(&mut mcp, &thread.id, /*cursor*/ None, Some(1)).await?;
|
|
assert_eq!(
|
|
first_page
|
|
.data
|
|
.into_iter()
|
|
.map(|queued_turn| queued_turn.id)
|
|
.collect::<Vec<_>>(),
|
|
vec![first.clone()]
|
|
);
|
|
let second_page =
|
|
list_queue_page(&mut mcp, &thread.id, first_page.next_cursor, Some(1)).await?;
|
|
assert_eq!(
|
|
second_page
|
|
.data
|
|
.into_iter()
|
|
.map(|queued_turn| queued_turn.id)
|
|
.collect::<Vec<_>>(),
|
|
vec![second.clone()]
|
|
);
|
|
assert_eq!(second_page.next_cursor, None);
|
|
|
|
let reorder_request_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/reorder",
|
|
Some(serde_json::to_value(ThreadQueueReorderParams {
|
|
thread_id: thread.id.clone(),
|
|
queued_turn_ids: vec![second.clone(), first.clone()],
|
|
})?),
|
|
)
|
|
.await?;
|
|
let reorder_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(reorder_request_id)),
|
|
)
|
|
.await??;
|
|
let ThreadQueueReorderResponse { queued_turns } = to_response(reorder_response)?;
|
|
assert_eq!(
|
|
queued_turns
|
|
.into_iter()
|
|
.map(|queued_turn| queued_turn.id)
|
|
.collect::<Vec<_>>(),
|
|
vec![second.clone(), first.clone()]
|
|
);
|
|
|
|
delete_queue_turn(&mut mcp, &thread.id, &second).await?;
|
|
delete_queue_turn(&mut mcp, &thread.id, &first).await?;
|
|
assert!(list_queue_ids(&mut mcp, &thread.id).await?.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn initialize_experimental(mcp: &mut McpProcess) -> Result<()> {
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.initialize_with_capabilities(
|
|
ClientInfo {
|
|
name: "thread-queue-tests".to_string(),
|
|
title: None,
|
|
version: "0.0.0".to_string(),
|
|
},
|
|
Some(InitializeCapabilities {
|
|
experimental_api: true,
|
|
opt_out_notification_methods: None,
|
|
request_attestation: false,
|
|
}),
|
|
),
|
|
)
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_thread(mcp: &mut McpProcess) -> Result<codex_app_server_protocol::Thread> {
|
|
let request_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response(response)?;
|
|
Ok(thread)
|
|
}
|
|
|
|
async fn queue_turn(mcp: &mut McpProcess, thread_id: &str, text: &str) -> Result<String> {
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/add",
|
|
Some(serde_json::to_value(ThreadQueueAddParams {
|
|
thread_id: thread_id.to_string(),
|
|
submission: text_submission(text).into(),
|
|
})?),
|
|
)
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ThreadQueueAddResponse { queued_turn } = to_response(response)?;
|
|
Ok(queued_turn.id)
|
|
}
|
|
|
|
async fn list_queue_ids(mcp: &mut McpProcess, thread_id: &str) -> Result<Vec<String>> {
|
|
let ThreadQueueListResponse { data, .. } =
|
|
list_queue_page(mcp, thread_id, /*cursor*/ None, /*limit*/ None).await?;
|
|
Ok(data.into_iter().map(|queued_turn| queued_turn.id).collect())
|
|
}
|
|
|
|
async fn list_queue_page(
|
|
mcp: &mut McpProcess,
|
|
thread_id: &str,
|
|
cursor: Option<String>,
|
|
limit: Option<u32>,
|
|
) -> Result<ThreadQueueListResponse> {
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/list",
|
|
Some(serde_json::to_value(ThreadQueueListParams {
|
|
thread_id: thread_id.to_string(),
|
|
cursor,
|
|
limit,
|
|
})?),
|
|
)
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
to_response(response)
|
|
}
|
|
|
|
async fn delete_queue_turn(
|
|
mcp: &mut McpProcess,
|
|
thread_id: &str,
|
|
queued_turn_id: &str,
|
|
) -> Result<()> {
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/delete",
|
|
Some(serde_json::to_value(ThreadQueueDeleteParams {
|
|
thread_id: thread_id.to_string(),
|
|
queued_turn_id: queued_turn_id.to_string(),
|
|
})?),
|
|
)
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ThreadQueueDeleteResponse { deleted } = to_response(response)?;
|
|
assert!(deleted);
|
|
Ok(())
|
|
}
|
|
|
|
async fn set_queue_feature(mcp: &mut McpProcess, enabled: bool) -> Result<()> {
|
|
let request_id = mcp
|
|
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
|
enablement: BTreeMap::from([("app_server_queue".to_string(), enabled)]),
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let ExperimentalFeatureEnablementSetResponse { enablement } = to_response(response)?;
|
|
assert_eq!(
|
|
enablement,
|
|
BTreeMap::from([("app_server_queue".to_string(), enabled)])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn text_submission(text: &str) -> TurnSubmission {
|
|
TurnSubmission {
|
|
input: vec![V2UserInput::Text {
|
|
text: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn write_queue_test_config(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
|
|
write_queue_test_config_with_feature(codex_home, server_uri, /*app_server_queue*/ true)
|
|
}
|
|
|
|
fn write_queue_test_config_with_feature(
|
|
codex_home: &std::path::Path,
|
|
server_uri: &str,
|
|
app_server_queue: bool,
|
|
) -> std::io::Result<()> {
|
|
write_queue_test_config_with_optional_feature(codex_home, server_uri, Some(app_server_queue))
|
|
}
|
|
|
|
fn write_queue_test_config_without_feature(
|
|
codex_home: &std::path::Path,
|
|
server_uri: &str,
|
|
) -> std::io::Result<()> {
|
|
write_queue_test_config_with_optional_feature(
|
|
codex_home, server_uri, /*app_server_queue*/ None,
|
|
)
|
|
}
|
|
|
|
fn write_queue_test_config_with_optional_feature(
|
|
codex_home: &std::path::Path,
|
|
server_uri: &str,
|
|
app_server_queue: Option<bool>,
|
|
) -> std::io::Result<()> {
|
|
let feature_config = app_server_queue
|
|
.map(|enabled| format!("\n[features]\napp_server_queue = {enabled}\n"))
|
|
.unwrap_or_default();
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"
|
|
model = "mock-model"
|
|
approval_policy = "never"
|
|
sandbox_mode = "read-only"
|
|
model_provider = "mock_provider"
|
|
{feature_config}
|
|
|
|
[model_providers.mock_provider]
|
|
name = "Mock provider for test"
|
|
base_url = "{server_uri}/v1"
|
|
wire_api = "responses"
|
|
request_max_retries = 0
|
|
stream_max_retries = 0
|
|
"#
|
|
),
|
|
)
|
|
}
|