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 20:08:15 +02:00
committed by GitHub
parent 336f907ec1
commit ee40dddbf6
5 changed files with 216 additions and 31 deletions
@@ -2,6 +2,7 @@ use core_test_support::test_codex::local_selections;
use std::sync::Arc;
use codex_core::CodexThread;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::items::TurnItem;
use codex_protocol::models::PermissionProfile;
@@ -206,6 +207,78 @@ async fn wait_for_turn_complete(codex: &CodexThread) {
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
}
#[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";
const INITIAL_PROMPT: &str = "wait for an agent";
const STEER_PROMPT: &str = "stop waiting and continue";
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_function_call(
WAIT_CALL_ID,
"wait_agent",
r#"{"timeout_ms":10000}"#,
)),
chunk(ev_completed("resp-1")),
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
let codex = test_codex()
.with_model("gpt-5.4")
.with_config(|config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
})
.build_with_streaming_server(&server)
.await
.expect("build Codex test session")
.codex;
submit_user_input(&codex, INITIAL_PROMPT).await;
wait_for_event(&codex, |event| {
matches!(event, EventMsg::CollabWaitingBegin(_))
})
.await;
steer_user_input(&codex, STEER_PROMPT).await;
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 2);
let second: Value = from_slice(&requests[1]).expect("parse second request");
let relevant_user_input = message_input_texts(&second, "user")
.into_iter()
.filter(|text| text == INITIAL_PROMPT || text == STEER_PROMPT)
.collect::<Vec<_>>();
assert_eq!(
relevant_user_input,
vec![INITIAL_PROMPT.to_string(), STEER_PROMPT.to_string()]
);
let wait_output = second["input"]
.as_array()
.expect("second request input")
.iter()
.find(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some(WAIT_CALL_ID)
})
.and_then(|item| item.get("output"))
.and_then(Value::as_str)
.expect("wait_agent output");
assert_eq!(
serde_json::from_str::<Value>(wait_output).expect("parse wait_agent output"),
json!({
"message": "Wait interrupted by new input.",
"timed_out": false,
})
);
server.shutdown().await;
}
fn assert_two_responses_input_snapshot(snapshot_name: &str, requests: &[Vec<u8>]) {
assert_eq!(requests.len(), 2);
let options = ContextSnapshotOptions::default().strip_capability_instructions();