mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
## What changed - Remove the legacy `shell_command` handler and runtime, leaving `exec_command` and `write_stdin` as the shell execution tools. - Treat legacy `shell_command` model metadata as `unified_exec`, and normalize legacy user opt-outs so they do not disable command execution. Managed feature requirements and `shell_tool` can still disable it. - Preserve shell approvals, sandboxing, zsh-fork support, and output truncation through the unified execution path. ## Testing - Cover legacy configuration and model-metadata compatibility. - Exercise unified shell execution, approvals, truncation, and `apply_patch` serialization across the app-server and core test suites. GitOrigin-RevId: 5c2fd6164fc3519cdae4944cb9db276b8467311c
979 lines
34 KiB
Rust
979 lines
34 KiB
Rust
use std::time::Duration;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use app_test_support::MockResponsesConfig;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::create_escalated_command_execution_sse_response;
|
|
use app_test_support::create_final_assistant_message_sse_response;
|
|
use app_test_support::create_mock_responses_server_sequence;
|
|
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
|
use codex_app_server_protocol::ClientInfo;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
|
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
|
use codex_app_server_protocol::InitializeCapabilities;
|
|
use codex_app_server_protocol::ItemStartedNotification;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::QueuedSubmission;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ServerRequest;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
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::ThreadQueueStartParams;
|
|
use codex_app_server_protocol::ThreadQueueStartResponse;
|
|
use codex_app_server_protocol::ThreadQueueUpdateParams;
|
|
use codex_app_server_protocol::ThreadQueueUpdateResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::TurnCompletedNotification;
|
|
use codex_app_server_protocol::TurnInterruptParams;
|
|
use codex_app_server_protocol::TurnInterruptResponse;
|
|
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 core_test_support::skip_if_remote;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
use wiremock::MockServer;
|
|
|
|
const READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
|
|
|
|
#[tokio::test]
|
|
async fn queue_requires_experimental_handshake() -> Result<()> {
|
|
let (mut app, codex_home, _server) = queue_app(Vec::new()).await?;
|
|
let thread = app.start_thread(ThreadStartParams::default()).await?.thread;
|
|
let queue = list_queue(&mut app, &thread.id).await?;
|
|
assert!(queue.data.is_empty());
|
|
drop(app);
|
|
|
|
let mut app = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build()
|
|
.await?;
|
|
app.initialize_with_capabilities(
|
|
ClientInfo {
|
|
name: "queue-experimental-gate".to_string(),
|
|
title: None,
|
|
version: "0.1.0".to_string(),
|
|
},
|
|
Some(InitializeCapabilities::default()),
|
|
)
|
|
.await?;
|
|
let request_id = app
|
|
.send_raw_request("thread/start", Some(json!({})))
|
|
.await?;
|
|
let thread = timeout(
|
|
READ_TIMEOUT,
|
|
app.read_response::<ThreadStartResponse>(request_id),
|
|
)
|
|
.await??
|
|
.thread;
|
|
let request_id = app
|
|
.send_raw_request(
|
|
"thread/queue/list",
|
|
Some(serde_json::to_value(ThreadQueueListParams {
|
|
thread_id: thread.id,
|
|
cursor: None,
|
|
limit: None,
|
|
})?),
|
|
)
|
|
.await?;
|
|
let error: JSONRPCError = timeout(
|
|
READ_TIMEOUT,
|
|
app.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert!(error.error.message.contains("experimental"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_crud_preserves_identity_order_and_notifications() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"uses a host-local command and cwd fixture unavailable to remote executors"
|
|
);
|
|
|
|
let responses = vec![
|
|
blocked_turn_response()?,
|
|
create_final_assistant_message_sse_response("active done")?,
|
|
create_final_assistant_message_sse_response("queued done")?,
|
|
];
|
|
let (mut app, _codex_home, _server) = queue_app(responses).await?;
|
|
let thread_id = app
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let (_, approval_id) = start_blocked_turn(&mut app, &thread_id).await?;
|
|
let first = queue_item(
|
|
&mut app,
|
|
ThreadQueueAddParams {
|
|
client_user_message_id: "first-client-message".to_string(),
|
|
..submission(&thread_id, "first")
|
|
},
|
|
)
|
|
.await?;
|
|
let second = queue_item(&mut app, submission(&thread_id, "second")).await?;
|
|
let first_change: ThreadQueueChangedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("thread/queue/changed")).await??;
|
|
let second_change: ThreadQueueChangedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("thread/queue/changed")).await??;
|
|
assert_eq!(
|
|
first_change,
|
|
ThreadQueueChangedNotification {
|
|
thread_id: thread_id.clone(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
second_change,
|
|
ThreadQueueChangedNotification {
|
|
thread_id: thread_id.clone(),
|
|
}
|
|
);
|
|
|
|
let updated: ThreadQueueUpdateResponse = app
|
|
.request(|request_id| ClientRequest::ThreadQueueUpdate {
|
|
request_id,
|
|
params: ThreadQueueUpdateParams {
|
|
thread_id: thread_id.clone(),
|
|
queued_submission_id: first.id.clone(),
|
|
input: vec![text("first edited")],
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(updated.queued_submission.id, first.id);
|
|
assert_eq!(
|
|
updated.queued_submission.client_user_message_id,
|
|
first.client_user_message_id
|
|
);
|
|
assert_eq!(updated.queued_submission.input, vec![text("first edited")]);
|
|
let update_change: ThreadQueueChangedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("thread/queue/changed")).await??;
|
|
assert_eq!(
|
|
update_change,
|
|
ThreadQueueChangedNotification {
|
|
thread_id: thread_id.clone(),
|
|
}
|
|
);
|
|
|
|
let invalid_reorder = app
|
|
.send_raw_request(
|
|
"thread/queue/reorder",
|
|
Some(serde_json::to_value(ThreadQueueReorderParams {
|
|
thread_id: thread_id.clone(),
|
|
queued_submission_ids: vec![first.id.clone()],
|
|
})?),
|
|
)
|
|
.await?;
|
|
let error: JSONRPCError = timeout(
|
|
READ_TIMEOUT,
|
|
app.read_stream_until_error_message(RequestId::Integer(invalid_reorder)),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
error.error.message,
|
|
"queue reorder must include every queued submission exactly once"
|
|
);
|
|
|
|
let _: ThreadQueueReorderResponse = app
|
|
.request(|request_id| ClientRequest::ThreadQueueReorder {
|
|
request_id,
|
|
params: ThreadQueueReorderParams {
|
|
thread_id: thread_id.clone(),
|
|
queued_submission_ids: vec![second.id.clone(), first.id.clone()],
|
|
},
|
|
})
|
|
.await?;
|
|
let reorder_change: ThreadQueueChangedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("thread/queue/changed")).await??;
|
|
assert_eq!(
|
|
reorder_change,
|
|
ThreadQueueChangedNotification {
|
|
thread_id: thread_id.clone(),
|
|
}
|
|
);
|
|
let deleted: ThreadQueueDeleteResponse = app
|
|
.request(|request_id| ClientRequest::ThreadQueueDelete {
|
|
request_id,
|
|
params: ThreadQueueDeleteParams {
|
|
thread_id: thread_id.clone(),
|
|
queued_submission_id: second.id,
|
|
},
|
|
})
|
|
.await?;
|
|
assert!(deleted.deleted);
|
|
let delete_change: ThreadQueueChangedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("thread/queue/changed")).await??;
|
|
assert_eq!(
|
|
delete_change,
|
|
ThreadQueueChangedNotification {
|
|
thread_id: thread_id.clone(),
|
|
}
|
|
);
|
|
|
|
decline_approval(&mut app, approval_id).await?;
|
|
for _ in 0..2 {
|
|
let _: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
}
|
|
let drain_change: ThreadQueueChangedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("thread/queue/changed")).await??;
|
|
assert_eq!(drain_change, ThreadQueueChangedNotification { thread_id });
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_list_returns_ordered_pages_and_lightweight_notifications() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"uses a host-local command and cwd fixture unavailable to remote executors"
|
|
);
|
|
|
|
let (mut app, _codex_home, _server) = queue_app(vec![blocked_turn_response()?]).await?;
|
|
let thread_id = app
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let _blocked = start_blocked_turn(&mut app, &thread_id).await?;
|
|
let first = queue_item(
|
|
&mut app,
|
|
submission(&thread_id, "fitting queued submission"),
|
|
)
|
|
.await?;
|
|
let queued = queue_item(
|
|
&mut app,
|
|
ThreadQueueAddParams {
|
|
thread_id: thread_id.clone(),
|
|
input: vec![text(&"x".repeat(64 * 1024))],
|
|
client_user_message_id: "oversized-snapshot".to_string(),
|
|
},
|
|
)
|
|
.await?;
|
|
let initial_change: ThreadQueueChangedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("thread/queue/changed")).await??;
|
|
assert_eq!(
|
|
initial_change,
|
|
ThreadQueueChangedNotification {
|
|
thread_id: thread_id.clone(),
|
|
}
|
|
);
|
|
let changed: ThreadQueueChangedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("thread/queue/changed")).await??;
|
|
assert_eq!(
|
|
changed,
|
|
ThreadQueueChangedNotification {
|
|
thread_id: thread_id.clone(),
|
|
}
|
|
);
|
|
let first_page: ThreadQueueListResponse = app
|
|
.request(|request_id| ClientRequest::ThreadQueueList {
|
|
request_id,
|
|
params: ThreadQueueListParams {
|
|
thread_id: thread_id.clone(),
|
|
cursor: None,
|
|
limit: Some(1),
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(first_page.data, vec![first.clone()]);
|
|
assert_eq!(first_page.next_cursor, Some("1".to_string()));
|
|
let second_page: ThreadQueueListResponse = app
|
|
.request(|request_id| ClientRequest::ThreadQueueList {
|
|
request_id,
|
|
params: ThreadQueueListParams {
|
|
thread_id: thread_id.clone(),
|
|
cursor: first_page.next_cursor,
|
|
limit: Some(1),
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(second_page.data, vec![queued.clone()]);
|
|
assert_eq!(second_page.next_cursor, None);
|
|
assert_eq!(
|
|
list_queue(&mut app, &thread_id).await?.data,
|
|
vec![first, queued]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_rejects_messages_after_reaching_its_capacity() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"uses a host-local command and cwd fixture unavailable to remote executors"
|
|
);
|
|
|
|
let (mut app, _codex_home, _server) = queue_app(vec![blocked_turn_response()?]).await?;
|
|
let thread_id = app
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let _blocked = start_blocked_turn(&mut app, &thread_id).await?;
|
|
|
|
for index in 0..100 {
|
|
queue_item(&mut app, submission(&thread_id, &format!("queued {index}"))).await?;
|
|
}
|
|
assert_eq!(list_queue(&mut app, &thread_id).await?.data.len(), 100);
|
|
|
|
let request_id = app
|
|
.send_raw_request(
|
|
"thread/queue/add",
|
|
Some(serde_json::to_value(submission(
|
|
&thread_id,
|
|
"one too many",
|
|
))?),
|
|
)
|
|
.await?;
|
|
let error: JSONRPCError = timeout(
|
|
READ_TIMEOUT,
|
|
app.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
error.error.message,
|
|
"queue cannot contain more than 100 submissions"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn idle_queue_dispatch_preserves_client_id() -> Result<()> {
|
|
let responses = vec![create_final_assistant_message_sse_response("queued done")?];
|
|
let (mut app, _codex_home, server) = queue_app(responses).await?;
|
|
let thread_id = app
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let queued_submission = ThreadQueueAddParams {
|
|
thread_id: thread_id.clone(),
|
|
input: vec![text("durable queued message")],
|
|
client_user_message_id: "stable-queued-client-id".to_string(),
|
|
};
|
|
let queued = queue_item(&mut app, queued_submission.clone()).await?;
|
|
assert_eq!(
|
|
queued.client_user_message_id,
|
|
queued_submission.client_user_message_id
|
|
);
|
|
let started: ItemStartedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("item/started")).await??;
|
|
let ThreadItem::UserMessage {
|
|
client_id, content, ..
|
|
} = started.item
|
|
else {
|
|
anyhow::bail!("queued turn did not begin with its user message");
|
|
};
|
|
assert_eq!(client_id.as_deref(), Some("stable-queued-client-id"));
|
|
assert_eq!(content, queued_submission.input);
|
|
let completed: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
assert_eq!(completed.thread_id, thread_id);
|
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
|
assert!(list_queue(&mut app, &thread_id).await?.data.is_empty());
|
|
|
|
let requests = server
|
|
.received_requests()
|
|
.await
|
|
.context("mock request capture unavailable")?;
|
|
let request = requests
|
|
.iter()
|
|
.find(|request| request.url.path().ends_with("/responses"))
|
|
.context("queued turn did not reach the model")?;
|
|
let body = request.body_json::<Value>()?;
|
|
assert!(body["input"].to_string().contains("durable queued message"));
|
|
let metadata_header = request
|
|
.headers
|
|
.get("x-codex-turn-metadata")
|
|
.context("queued model request is missing its x-codex-turn-metadata header")?
|
|
.to_str()
|
|
.context("queued turn metadata header is not valid ASCII")?;
|
|
let metadata: Value = serde_json::from_str(metadata_header)?;
|
|
assert_eq!(metadata["thread_id"].as_str(), Some(thread_id.as_str()));
|
|
assert_eq!(
|
|
metadata["turn_id"].as_str(),
|
|
Some(completed.turn.id.as_str())
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_thread_resume_dispatches_a_persisted_queued_submission() -> Result<()> {
|
|
let responses = vec![
|
|
create_final_assistant_message_sse_response("materialized thread")?,
|
|
create_final_assistant_message_sse_response("cold-resumed queued message")?,
|
|
];
|
|
let (mut first, codex_home, _server) = queue_app(responses).await?;
|
|
let thread_id = first
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let _: TurnStartResponse = first
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
input: vec![text("materialize the thread before restarting")],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
let _: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, first.read_notification("turn/completed")).await??;
|
|
drop(first);
|
|
|
|
let mut resumed = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let queued = queue_item(
|
|
&mut resumed,
|
|
ThreadQueueAddParams {
|
|
client_user_message_id: "cold-resumed-queue-item".to_string(),
|
|
..submission(&thread_id, "dispatch this after a cold thread resume")
|
|
},
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
list_queue(&mut resumed, &thread_id).await?.data,
|
|
vec![queued]
|
|
);
|
|
let request_id = resumed
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let response: ThreadResumeResponse =
|
|
timeout(READ_TIMEOUT, resumed.read_response(request_id)).await??;
|
|
assert_eq!(thread_id, response.thread.id);
|
|
|
|
let started: ItemStartedNotification =
|
|
timeout(READ_TIMEOUT, resumed.read_notification("item/started")).await??;
|
|
let ThreadItem::UserMessage {
|
|
client_id, content, ..
|
|
} = started.item
|
|
else {
|
|
anyhow::bail!("cold resume did not start the persisted queued user message");
|
|
};
|
|
assert_eq!(client_id.as_deref(), Some("cold-resumed-queue-item"));
|
|
assert_eq!(
|
|
content,
|
|
vec![text("dispatch this after a cold thread resume")]
|
|
);
|
|
let completed: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, resumed.read_notification("turn/completed")).await??;
|
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
|
assert!(list_queue(&mut resumed, &thread_id).await?.data.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupt_preserves_queue_and_queue_start_can_resume_a_non_head_item() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"uses a host-local command and cwd fixture unavailable to remote executors"
|
|
);
|
|
|
|
let responses = vec![
|
|
blocked_turn_response()?,
|
|
create_final_assistant_message_sse_response("first queued message done")?,
|
|
create_final_assistant_message_sse_response("second queued message done")?,
|
|
create_final_assistant_message_sse_response("message added after interruption done")?,
|
|
create_final_assistant_message_sse_response("message added after cold resume done")?,
|
|
];
|
|
let (mut app, codex_home, _server) = queue_app(responses).await?;
|
|
let thread_id = app
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let (active_turn_id, _approval_id) = start_blocked_turn(&mut app, &thread_id).await?;
|
|
|
|
let first = queue_item(&mut app, submission(&thread_id, "first queued message")).await?;
|
|
let second = queue_item(
|
|
&mut app,
|
|
ThreadQueueAddParams {
|
|
client_user_message_id: "second-queued-client-id".to_string(),
|
|
..submission(&thread_id, "second queued message")
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let _: TurnInterruptResponse = app
|
|
.request(|request_id| ClientRequest::TurnInterrupt {
|
|
request_id,
|
|
params: TurnInterruptParams {
|
|
thread_id: thread_id.clone(),
|
|
turn_id: active_turn_id,
|
|
},
|
|
})
|
|
.await?;
|
|
let interrupted: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
assert_eq!(interrupted.turn.status, TurnStatus::Interrupted);
|
|
assert_eq!(
|
|
vec![first.clone(), second.clone()],
|
|
list_queue(&mut app, &thread_id).await?.data
|
|
);
|
|
|
|
let added_after_interrupt = queue_item(
|
|
&mut app,
|
|
submission(&thread_id, "message added after interruption"),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
vec![first.clone(), second.clone(), added_after_interrupt.clone(),],
|
|
list_queue(&mut app, &thread_id).await?.data
|
|
);
|
|
|
|
let metadata_resume_request_id = app
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let metadata_resumed: ThreadResumeResponse =
|
|
timeout(READ_TIMEOUT, app.read_response(metadata_resume_request_id)).await??;
|
|
assert_eq!(metadata_resumed.thread.id, thread_id);
|
|
assert_eq!(
|
|
vec![first.clone(), second.clone(), added_after_interrupt.clone(),],
|
|
list_queue(&mut app, &thread_id).await?.data
|
|
);
|
|
|
|
let resume_request_id = app
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resumed: ThreadResumeResponse =
|
|
timeout(READ_TIMEOUT, app.read_response(resume_request_id)).await??;
|
|
assert_eq!(resumed.thread.id, thread_id);
|
|
assert_eq!(
|
|
vec![first.clone(), second.clone(), added_after_interrupt.clone(),],
|
|
list_queue(&mut app, &thread_id).await?.data
|
|
);
|
|
|
|
drop(app);
|
|
let mut app = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let cold_resume_request_id = app
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let cold_resumed: ThreadResumeResponse =
|
|
timeout(READ_TIMEOUT, app.read_response(cold_resume_request_id)).await??;
|
|
assert_eq!(cold_resumed.thread.id, thread_id);
|
|
|
|
let added_after_cold_resume = queue_item(
|
|
&mut app,
|
|
submission(&thread_id, "message added after cold resume"),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
vec![
|
|
first,
|
|
second.clone(),
|
|
added_after_interrupt,
|
|
added_after_cold_resume
|
|
],
|
|
list_queue(&mut app, &thread_id).await?.data
|
|
);
|
|
|
|
let started: ThreadQueueStartResponse = app
|
|
.request(|request_id| ClientRequest::ThreadQueueStart {
|
|
request_id,
|
|
params: ThreadQueueStartParams {
|
|
thread_id: thread_id.clone(),
|
|
queued_submission_id: Some(second.id),
|
|
},
|
|
})
|
|
.await?;
|
|
for index in 0..4 {
|
|
let completed: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
if index == 0 {
|
|
assert_eq!(completed.turn.id, started.turn.id);
|
|
}
|
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
|
}
|
|
assert!(list_queue(&mut app, &thread_id).await?.data.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_start_while_active_returns_busy_and_preserves_the_queue() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"uses a host-local command and cwd fixture unavailable to remote executors"
|
|
);
|
|
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
blocked_turn_response()?,
|
|
create_final_assistant_message_sse_response("active turn done")?,
|
|
create_final_assistant_message_sse_response("queued message done")?,
|
|
])
|
|
.await;
|
|
let (mut app, _codex_home, _server) = queue_app_with_server(server).await?;
|
|
let thread_id = app
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let (_, approval_id) = start_blocked_turn(&mut app, &thread_id).await?;
|
|
let queued = queue_item(
|
|
&mut app,
|
|
ThreadQueueAddParams {
|
|
client_user_message_id: "active-queued-client-id".to_string(),
|
|
..submission(&thread_id, "send this queued message now")
|
|
},
|
|
)
|
|
.await?;
|
|
assert_eq!(queued.client_user_message_id, "active-queued-client-id");
|
|
|
|
let start_request_id = app
|
|
.send_raw_request("thread/queue/start", Some(json!({ "threadId": thread_id })))
|
|
.await?;
|
|
let error: JSONRPCError = timeout(
|
|
READ_TIMEOUT,
|
|
app.read_stream_until_error_message(RequestId::Integer(start_request_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(error.error.code, -32600);
|
|
assert_eq!(
|
|
error.error.message,
|
|
"thread already has an active or pending turn"
|
|
);
|
|
assert_eq!(list_queue(&mut app, &thread_id).await?.data, vec![queued]);
|
|
decline_approval(&mut app, approval_id).await?;
|
|
|
|
let started_item = loop {
|
|
let started_item: ItemStartedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("item/started")).await??;
|
|
if matches!(
|
|
&started_item.item,
|
|
ThreadItem::UserMessage { client_id, .. }
|
|
if client_id.as_deref() == Some("active-queued-client-id")
|
|
) {
|
|
break started_item;
|
|
}
|
|
};
|
|
let ThreadItem::UserMessage { client_id, .. } = started_item.item else {
|
|
anyhow::bail!("queued message did not start after the active turn completed");
|
|
};
|
|
assert_eq!(client_id.as_deref(), Some("active-queued-client-id"));
|
|
|
|
let completed = loop {
|
|
let completed: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
if completed.turn.id == started_item.turn_id {
|
|
break completed;
|
|
}
|
|
};
|
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
|
assert!(list_queue(&mut app, &thread_id).await?.data.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_start_without_id_starts_the_head_when_idle() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"uses a host-local command and cwd fixture unavailable to remote executors"
|
|
);
|
|
|
|
let responses = vec![
|
|
blocked_turn_response()?,
|
|
create_final_assistant_message_sse_response("first queued message done")?,
|
|
create_final_assistant_message_sse_response("second queued message done")?,
|
|
];
|
|
let (mut app, _codex_home, _server) = queue_app(responses).await?;
|
|
let thread_id = app
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let (active_turn_id, _approval_id) = start_blocked_turn(&mut app, &thread_id).await?;
|
|
let first = queue_item(&mut app, submission(&thread_id, "first queued message")).await?;
|
|
let second = queue_item(&mut app, submission(&thread_id, "second queued message")).await?;
|
|
|
|
let _: TurnInterruptResponse = app
|
|
.request(|request_id| ClientRequest::TurnInterrupt {
|
|
request_id,
|
|
params: TurnInterruptParams {
|
|
thread_id: thread_id.clone(),
|
|
turn_id: active_turn_id,
|
|
},
|
|
})
|
|
.await?;
|
|
let interrupted: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
assert_eq!(interrupted.turn.status, TurnStatus::Interrupted);
|
|
assert_eq!(
|
|
list_queue(&mut app, &thread_id).await?.data,
|
|
vec![first.clone(), second]
|
|
);
|
|
|
|
let start_request_id = app
|
|
.send_raw_request("thread/queue/start", Some(json!({ "threadId": thread_id })))
|
|
.await?;
|
|
let started: ThreadQueueStartResponse =
|
|
timeout(READ_TIMEOUT, app.read_response(start_request_id)).await??;
|
|
let started_item = loop {
|
|
let started_item: ItemStartedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("item/started")).await??;
|
|
if matches!(
|
|
&started_item.item,
|
|
ThreadItem::UserMessage { client_id, .. }
|
|
if client_id.as_deref() == Some(first.client_user_message_id.as_str())
|
|
) {
|
|
break started_item;
|
|
}
|
|
};
|
|
assert_eq!(started_item.turn_id, started.turn.id);
|
|
for _ in 0..2 {
|
|
let completed: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
|
}
|
|
assert!(list_queue(&mut app, &thread_id).await?.data.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_new_turn_preserves_queued_messages_until_it_completes() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"uses a host-local command and cwd fixture unavailable to remote executors"
|
|
);
|
|
|
|
let responses = vec![
|
|
blocked_turn_response()?,
|
|
blocked_turn_response()?,
|
|
create_final_assistant_message_sse_response("new turn done")?,
|
|
create_final_assistant_message_sse_response("first queued message done")?,
|
|
create_final_assistant_message_sse_response("second queued message done")?,
|
|
];
|
|
let (mut app, _codex_home, _server) = queue_app(responses).await?;
|
|
let thread_id = app
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id;
|
|
let (active_turn_id, _approval_id) = start_blocked_turn(&mut app, &thread_id).await?;
|
|
|
|
let first = queue_item(
|
|
&mut app,
|
|
ThreadQueueAddParams {
|
|
client_user_message_id: "first-queued-client-id".to_string(),
|
|
..submission(&thread_id, "first queued message")
|
|
},
|
|
)
|
|
.await?;
|
|
let second = queue_item(
|
|
&mut app,
|
|
ThreadQueueAddParams {
|
|
client_user_message_id: "second-queued-client-id".to_string(),
|
|
..submission(&thread_id, "second queued message")
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let _: TurnInterruptResponse = app
|
|
.request(|request_id| ClientRequest::TurnInterrupt {
|
|
request_id,
|
|
params: TurnInterruptParams {
|
|
thread_id: thread_id.clone(),
|
|
turn_id: active_turn_id,
|
|
},
|
|
})
|
|
.await?;
|
|
let interrupted: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
assert_eq!(interrupted.turn.status, TurnStatus::Interrupted);
|
|
|
|
let _: TurnStartResponse = app
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
input: first.input.clone(),
|
|
client_user_message_id: Some(first.client_user_message_id.clone()),
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
let approval = timeout(READ_TIMEOUT, app.read_stream_until_request_message()).await??;
|
|
let ServerRequest::CommandExecutionRequestApproval {
|
|
request_id: new_approval_id,
|
|
..
|
|
} = approval
|
|
else {
|
|
anyhow::bail!("matching ordinary turn did not request command approval");
|
|
};
|
|
assert_eq!(
|
|
vec![first, second],
|
|
list_queue(&mut app, &thread_id).await?.data
|
|
);
|
|
|
|
decline_approval(&mut app, new_approval_id).await?;
|
|
for _ in 0..3 {
|
|
let completed: TurnCompletedNotification =
|
|
timeout(READ_TIMEOUT, app.read_notification("turn/completed")).await??;
|
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
|
}
|
|
assert!(list_queue(&mut app, &thread_id).await?.data.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn queue_app(responses: Vec<String>) -> Result<(TestAppServer, TempDir, MockServer)> {
|
|
let server = create_mock_responses_server_sequence(responses).await;
|
|
queue_app_with_server(server).await
|
|
}
|
|
|
|
async fn queue_app_with_server(server: MockServer) -> Result<(TestAppServer, TempDir, MockServer)> {
|
|
let codex_home = TempDir::new()?;
|
|
let config = MockResponsesConfig::new(&server.uri())
|
|
.with_approval_policy("on-request")
|
|
.with_root_config(r#"approvals_reviewer = "user""#);
|
|
config.write(codex_home.path())?;
|
|
let app = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
Ok((app, codex_home, server))
|
|
}
|
|
|
|
fn blocked_turn_response() -> Result<String> {
|
|
#[cfg(target_os = "windows")]
|
|
let shell_command = vec![
|
|
"powershell".to_string(),
|
|
"-Command".to_string(),
|
|
"Start-Sleep -Seconds 10".to_string(),
|
|
];
|
|
#[cfg(not(target_os = "windows"))]
|
|
let shell_command = vec![
|
|
"python3".to_string(),
|
|
"-c".to_string(),
|
|
"import time; time.sleep(10)".to_string(),
|
|
];
|
|
|
|
create_escalated_command_execution_sse_response(
|
|
shell_command,
|
|
/*workdir*/ None,
|
|
/*timeout_ms*/ Some(10_000),
|
|
"queue-blocked-command",
|
|
)
|
|
}
|
|
|
|
async fn start_blocked_turn(
|
|
app: &mut TestAppServer,
|
|
thread_id: &str,
|
|
) -> Result<(String, RequestId)> {
|
|
let started: TurnStartResponse = app
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
input: vec![text("start an approval-blocked turn")],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
let approval = timeout(READ_TIMEOUT, app.read_stream_until_request_message()).await??;
|
|
let ServerRequest::CommandExecutionRequestApproval { request_id, .. } = approval else {
|
|
anyhow::bail!("active turn did not request command approval");
|
|
};
|
|
Ok((started.turn.id, request_id))
|
|
}
|
|
|
|
async fn queue_item(
|
|
app: &mut TestAppServer,
|
|
params: ThreadQueueAddParams,
|
|
) -> Result<QueuedSubmission> {
|
|
let response: ThreadQueueAddResponse = app
|
|
.request(|request_id| ClientRequest::ThreadQueueAdd { request_id, params })
|
|
.await?;
|
|
Ok(response.queued_submission)
|
|
}
|
|
|
|
async fn list_queue(app: &mut TestAppServer, thread_id: &str) -> Result<ThreadQueueListResponse> {
|
|
let mut data = Vec::new();
|
|
let mut cursor = None;
|
|
loop {
|
|
let page: ThreadQueueListResponse = app
|
|
.request(|request_id| ClientRequest::ThreadQueueList {
|
|
request_id,
|
|
params: ThreadQueueListParams {
|
|
thread_id: thread_id.to_string(),
|
|
cursor,
|
|
limit: None,
|
|
},
|
|
})
|
|
.await?;
|
|
data.extend(page.data);
|
|
cursor = page.next_cursor;
|
|
if cursor.is_none() {
|
|
return Ok(ThreadQueueListResponse {
|
|
data,
|
|
next_cursor: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn decline_approval(app: &mut TestAppServer, request_id: RequestId) -> Result<()> {
|
|
app.send_response(
|
|
request_id,
|
|
serde_json::to_value(CommandExecutionRequestApprovalResponse {
|
|
decision: CommandExecutionApprovalDecision::Decline,
|
|
})?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn submission(thread_id: &str, value: &str) -> ThreadQueueAddParams {
|
|
ThreadQueueAddParams {
|
|
thread_id: thread_id.to_string(),
|
|
input: vec![text(value)],
|
|
client_user_message_id: format!("queued-{value}"),
|
|
}
|
|
}
|
|
|
|
fn text(value: &str) -> UserInput {
|
|
UserInput::Text {
|
|
text: value.to_string(),
|
|
text_elements: Vec::new(),
|
|
}
|
|
}
|