Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codex-rs/core/src/session/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ pub async fn inter_agent_communication(
.enqueue_mailbox_communication(communication)
.await;
crate::agent_communication::emit_agent_communication_receive(&sub_id);
if trigger_turn {
if trigger_turn || sess.has_outstanding_durable_sleep() {
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
.await;
}
Expand Down
20 changes: 16 additions & 4 deletions codex-rs/core/src/tasks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,9 +451,18 @@ impl Session {
turn.task = Some(running_task);
}

/// Returns whether an extension has marked this thread as durably asleep.
pub(crate) fn has_outstanding_durable_sleep(&self) -> bool {
self.services
.thread_extension_data
.get::<codex_extension_items::sleep::SleepItem>()
.is_some()
}

/// Starts a regular turn when the session is idle and pending work is waiting.
///
/// Pending work currently includes mailbox mail marked with `trigger_turn`.
/// Pending work includes mailbox mail marked with `trigger_turn`, or any mailbox mail while
/// an outstanding durable sleep is attached to the thread.
///
/// This helper generates a fresh sub-id for the synthetic turn before delegating to the
/// explicit-sub-id variant.
Expand All @@ -469,13 +478,16 @@ impl Session {
/// Starts a regular turn with the provided sub-id when pending work should wake an idle
/// session.
///
/// The turn is created only when there is mailbox mail marked with `trigger_turn`, and only
/// if the session is currently idle.
/// The turn is created only when the session is idle and mailbox mail either requests a turn
/// or can wake an outstanding durable sleep.
pub(crate) async fn maybe_start_turn_for_pending_work_with_sub_id(
self: &Arc<Self>,
sub_id: String,
) {
if !self.input_queue.has_trigger_turn_mailbox_items().await {
if !self.input_queue.has_pending_mailbox_items().await
|| (!self.input_queue.has_trigger_turn_mailbox_items().await
&& !self.has_outstanding_durable_sleep())
{
return;
}

Expand Down
64 changes: 63 additions & 1 deletion codex-rs/core/tests/suite/pending_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ async fn steer_user_input(codex: &CodexThread, text: &str) {
.expect("steer user input");
}

async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) {
async fn enqueue_queue_only_agent_mail(codex: &CodexThread, text: &str) {
codex
.submit(Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
Expand All @@ -206,6 +206,10 @@ async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) {
})
.await
.expect("submit queue-only agent mail");
}

async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) {
enqueue_queue_only_agent_mail(codex, text).await;
codex
.submit(Op::RealtimeConversationListVoices)
.await
Expand Down Expand Up @@ -294,6 +298,64 @@ async fn wait_for_sleep_item_completed(codex: &CodexThread, call_id: &str, durat
);
}

struct SleepingRootExtension;

impl codex_extension_api::ThreadLifecycleContributor<codex_core::config::Config>
for SleepingRootExtension
{
fn on_thread_start<'a>(
&'a self,
input: codex_extension_api::ThreadStartInput<'a, codex_core::config::Config>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
input.thread_store.insert(SleepItem {
id: "clock-wait-1".to_string(),
duration_ms: 60_000,
});
})
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn queue_only_agent_mail_wakes_sleeping_root_and_persists_message() {
const CHILD_MESSAGE: &str = "worker completed";

let (server, _completions) =
start_streaming_sse_server(vec![response_completed_chunks("resp-1")]).await;
let mut extensions =
codex_extension_api::ExtensionRegistryBuilder::<codex_core::config::Config>::new();
extensions.thread_lifecycle_contributor(Arc::new(SleepingRootExtension));
let codex = test_codex()
.with_model("gpt-5.4")
.with_extensions(Arc::new(extensions.build()))
.build_with_streaming_server(&server)
.await
.expect("build Codex test session")
.codex;

enqueue_queue_only_agent_mail(&codex, CHILD_MESSAGE).await;
wait_for_turn_complete(&codex).await;

assert_eq!(server.requests().await.len(), 1);
let history = codex
.load_history(/*include_archived*/ true)
.await
.expect("load persisted thread history");
assert!(history.items.iter().any(|item| {
matches!(
item,
RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::AgentMessage {
content,
..
}) if content.iter().any(|content| matches!(
content,
codex_protocol::models::AgentMessageInputContent::InputText { text }
if text == CHILD_MESSAGE
))
)
}));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn steer_interrupts_wait_agent_and_is_sent_in_follow_up_request() {
const WAIT_CALL_ID: &str = "wait-call";
Expand Down
Loading