core: let steer interrupt wait_agent (#28341)

## Why

`wait_agent` can block for a long timeout while waiting for sub-agent
mailbox activity. Although same-turn user steer is accepted during that
tool call, the input remains pending until the wait returns, so an
explicit request to change direction can appear unresponsive.

## What changed

- Notify active `wait_agent` calls when user input is steered into the
current turn.
- Check for already-pending steer input when subscribing so input that
races with tool startup is not missed.
- Distinguish mailbox activity, steered input, and timeout outcomes,
returning `Wait interrupted by new input.` for the steer path.
- Update the `wait_agent` tool description to document the early-return
behavior.

## Testing

- `just test -p codex-core input_queue_`
- `just test -p codex-core wait_agent`

The coverage includes steer notification before and after subscription,
plus an end-to-end test that verifies the interrupted wait result and
steered user input are both included exactly once in the follow-up model
request.
This commit is contained in:
jif
2026-06-15 19:08:15 +01:00
committed by GitHub
Unverified
parent 336f907ec1
commit ee40dddbf6
5 changed files with 216 additions and 31 deletions
@@ -255,7 +255,7 @@ pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec {
pub fn create_wait_agent_tool_v2(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for a mailbox update from any live agent, including queued messages and final-status notifications. Does not return the content; returns either a summary of which agents have updates (if any), or a timeout summary if no mailbox update arrives before the deadline."
description: "Wait for a mailbox update from any live agent, including queued messages and final-status notifications. The wait also ends early when new user input is steered into the active turn. Does not return the content; returns either a summary of which agents have updates (if any), an interruption summary for steered input, or a timeout summary if no activity arrives before the deadline."
.to_string(),
strict: false,
defer_loading: None,
@@ -1,4 +1,5 @@
use super::*;
use crate::session::InputQueueActivity;
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2;
use crate::turn_timing::now_unix_timestamp_ms;
@@ -65,7 +66,14 @@ impl Handler {
None => default_timeout_ms,
};
let mut mailbox_rx = session.input_queue.subscribe_mailbox().await;
let turn_state = session
.input_queue
.turn_state_for_sub_id(&session.active_turn, &turn.sub_id)
.await;
let (mut activity_rx, pending_activity) = session
.input_queue
.subscribe_activity(turn_state.as_deref())
.await;
session
.send_event(
@@ -82,8 +90,8 @@ impl Handler {
.await;
let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
let timed_out = !wait_for_mailbox_change(&mut mailbox_rx, deadline).await;
let result = WaitAgentResult::from_timed_out(timed_out);
let outcome = wait_for_activity(&mut activity_rx, pending_activity, deadline).await;
let result = WaitAgentResult::from_outcome(outcome);
session
.send_event(
@@ -122,15 +130,15 @@ pub(crate) struct WaitAgentResult {
}
impl WaitAgentResult {
fn from_timed_out(timed_out: bool) -> Self {
let message = if timed_out {
"Wait timed out."
} else {
"Wait completed."
fn from_outcome(outcome: WaitOutcome) -> Self {
let message = match outcome {
WaitOutcome::MailboxActivity => "Wait completed.",
WaitOutcome::Steered => "Wait interrupted by new input.",
WaitOutcome::TimedOut => "Wait timed out.",
};
Self {
message: message.to_string(),
timed_out,
timed_out: outcome == WaitOutcome::TimedOut,
}
}
}
@@ -153,12 +161,29 @@ impl ToolOutput for WaitAgentResult {
}
}
async fn wait_for_mailbox_change(
mailbox_rx: &mut tokio::sync::watch::Receiver<()>,
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WaitOutcome {
MailboxActivity,
Steered,
TimedOut,
}
async fn wait_for_activity(
activity_rx: &mut tokio::sync::watch::Receiver<InputQueueActivity>,
pending_activity: Option<InputQueueActivity>,
deadline: Instant,
) -> bool {
match timeout_at(deadline, mailbox_rx.changed()).await {
Ok(Ok(())) => true,
Ok(Err(_)) | Err(_) => false,
) -> WaitOutcome {
if let Some(activity) = pending_activity {
return match activity {
InputQueueActivity::Mailbox => WaitOutcome::MailboxActivity,
InputQueueActivity::Steer => WaitOutcome::Steered,
};
}
match timeout_at(deadline, activity_rx.changed()).await {
Ok(Ok(())) => match *activity_rx.borrow_and_update() {
InputQueueActivity::Mailbox => WaitOutcome::MailboxActivity,
InputQueueActivity::Steer => WaitOutcome::Steered,
},
Ok(Err(_)) | Err(_) => WaitOutcome::TimedOut,
}
}