Wake sleeping threads for queued agent mail (#34852)

## Why

An idle thread with an outstanding durable sleep must resume when agent work
arrives, even when that message would normally remain queued without triggering a
turn.

## What changed

- Treat any pending mailbox message as wake-up work while a durable sleep is
  attached to the thread.
- Keep requiring `trigger_turn` for idle threads that are not durably asleep.
- Cover waking a sleeping root thread from queue-only agent mail and persisting
  the message in thread history.

GitOrigin-RevId: e361ac1b104436d4d63aebfe5e2422691a1cc139
This commit is contained in:
Boyang Niu
2026-07-23 00:49:18 +00:00
committed by copyberry
parent b72079a2cf
commit 44d76c6a6d
3 changed files with 80 additions and 6 deletions

View File

@@ -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;
}

View File

@@ -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.
@@ -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;
}

View File

@@ -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(
@@ -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
@@ -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";