Skip to content

Commit 04fc75a

Browse files
pmccrary-oaicopyberry
authored andcommitted
Add conditional turn interruption that preserves pending input (#47340)
## Why Callers need to interrupt a specific active turn without discarding queued input. Tool callbacks must be able to await the interruption decision without blocking task cancellation. ## What changed - Add `CodexThread::interrupt_if_no_pending_input` and `Op::InterruptIfNoPendingInput`. - Check the turn identity and pending user input or mailbox items before taking the active task. Leave the turn running for stale or abandoned requests. - Acknowledge the decision before joining cancellation, and unblock pending submissions when the active task is cancelled. ## Testing Add regression coverage for successful interruption, stale turns, preservation of queued user input and agent mail, abandoned requests, and cancellation while the submission queue is full. GitOrigin-RevId: 42e31c09369fb070ba1b4b3c64ef45334dd872d8
1 parent 1b1b58f commit 04fc75a

7 files changed

Lines changed: 343 additions & 5 deletions

File tree

‎codex-rs/core/src/session/extension_interruption.rs‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,95 @@
11
//! Applies extension-requested interruption only if the selected turn is still active.
2-
//! The caller selects the warning; the host owns task cancellation and idle notification.
2+
//! Conditional interruption preserves queued input and acknowledges before joining the task.
33
44
use super::session::Session;
5+
use crate::codex_thread::CodexThread;
56
use codex_extension_api::ThreadIdleCause;
7+
use codex_protocol::error::CodexErr;
8+
use codex_protocol::error::Result as CodexResult;
69
use codex_protocol::protocol::EventMsg;
10+
use codex_protocol::protocol::Op;
711
use codex_protocol::protocol::TurnAbortReason;
812
use std::sync::Arc;
13+
use tokio::sync::oneshot;
14+
use tokio::sync::oneshot::error::TryRecvError;
15+
16+
#[cfg(test)]
17+
#[path = "extension_interruption_tests.rs"]
18+
mod tests;
19+
20+
impl CodexThread {
21+
/// Interrupts the named active turn unless it has queued input.
22+
/// Returns the decision before joining cancellation, so tool callbacks can await it.
23+
/// Like [`Op::Interrupt`], this does not notify idle contributors; the caller owns wakeup.
24+
pub async fn interrupt_if_no_pending_input(&self, turn_id: &str) -> CodexResult<bool> {
25+
let cancellation_token = {
26+
let active = self.session.active_turn.lock().await;
27+
let Some(task) = active.as_ref().and_then(|turn| turn.task.as_ref()) else {
28+
return Ok(false);
29+
};
30+
if task.turn_context.sub_id != turn_id {
31+
return Ok(false);
32+
}
33+
task.cancellation_token.clone()
34+
};
35+
let (reply, mut result) = oneshot::channel();
36+
tokio::select! {
37+
biased;
38+
result = async {
39+
self.submit(Op::InterruptIfNoPendingInput {
40+
turn_id: turn_id.to_owned(),
41+
reply,
42+
}).await?;
43+
(&mut result).await.map_err(|_| CodexErr::InternalAgentDied)
44+
} => result,
45+
// An earlier interrupt may already be waiting for this tool callback to finish.
46+
// Cancellation can also race with a decision sent by this request.
47+
_ = cancellation_token.cancelled() => match result.try_recv() {
48+
Ok(decision) => Ok(decision),
49+
Err(TryRecvError::Empty | TryRecvError::Closed) => Ok(false),
50+
},
51+
}
52+
}
53+
}
954

1055
impl Session {
56+
#[expect(
57+
clippy::await_holding_invalid_type,
58+
reason = "the turn identity and pending input check must be atomic with taking the task"
59+
)]
60+
pub(crate) async fn interrupt_turn_if_no_pending_input(
61+
self: &Arc<Self>,
62+
turn_id: &str,
63+
reply: oneshot::Sender<bool>,
64+
) {
65+
let active_turn = {
66+
let mut active = self.active_turn.lock().await;
67+
let Some(turn) = active.as_ref().filter(|turn| {
68+
turn.task
69+
.as_ref()
70+
.is_some_and(|task| task.turn_context.sub_id == turn_id)
71+
}) else {
72+
let _ = reply.send(false);
73+
return;
74+
};
75+
if !turn.turn_state.lock().await.pending_input.is_empty()
76+
|| self.input_queue.has_pending_mailbox_items().await
77+
|| reply.is_closed()
78+
{
79+
let _ = reply.send(false);
80+
return;
81+
}
82+
self.mark_interrupted();
83+
active.take()
84+
};
85+
// The caller may be inside the task that cancellation must join.
86+
let _ = reply.send(active_turn.is_some());
87+
if let Some(active_turn) = active_turn {
88+
self.finish_turn_abort(active_turn, TurnAbortReason::Interrupted)
89+
.await;
90+
}
91+
}
92+
1193
pub(crate) async fn interrupt_turn_with_warning(
1294
self: &Arc<Self>,
1395
turn_id: &str,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//! Regression coverage for cancellation while submitting a conditional interrupt.
2+
3+
use super::*;
4+
use crate::session::SessionIo;
5+
use crate::session::completed_session_loop_termination;
6+
use crate::session::tests::HeldStepTask;
7+
use crate::session::tests::make_session_and_context_with_rx;
8+
use crate::state::TaskKind;
9+
use crate::thread_startup_metadata::ThreadStartupMetadata;
10+
use codex_protocol::config_types::ApprovalsReviewer;
11+
use codex_protocol::protocol::AgentStatus;
12+
use codex_protocol::protocol::AskForApproval;
13+
use codex_protocol::protocol::SessionConfiguredEvent;
14+
use codex_protocol::protocol::SessionSource;
15+
use pretty_assertions::assert_eq;
16+
use std::time::Duration;
17+
use tokio::sync::Notify;
18+
use tokio::sync::watch;
19+
20+
#[tokio::test]
21+
async fn interrupt_if_no_pending_input_handles_cancelled_submission() {
22+
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
23+
session
24+
.spawn_task(
25+
Arc::clone(&turn_context),
26+
Vec::new(),
27+
HeldStepTask {
28+
kind: TaskKind::Regular,
29+
finish: Arc::new(Notify::new()),
30+
},
31+
)
32+
.await;
33+
let cancellation_token = session
34+
.active_turn
35+
.lock()
36+
.await
37+
.as_ref()
38+
.expect("active turn")
39+
.task
40+
.as_ref()
41+
.expect("active task")
42+
.cancellation_token
43+
.clone();
44+
let (tx_sub, rx_sub) = async_channel::bounded(/*cap*/ 1);
45+
let thread = CodexThread::new(
46+
Arc::clone(&session),
47+
SessionIo {
48+
tx_sub,
49+
rx_event,
50+
agent_status: watch::channel(AgentStatus::PendingInit).1,
51+
session_loop_termination: completed_session_loop_termination(),
52+
},
53+
ThreadStartupMetadata::from(&SessionConfiguredEvent {
54+
session_id: session.session_id(),
55+
thread_id: session.thread_id(),
56+
forked_from_id: None,
57+
parent_thread_id: None,
58+
thread_source: None,
59+
thread_name: None,
60+
model: "test".to_string(),
61+
model_provider_id: "test".to_string(),
62+
service_tier: None,
63+
approval_policy: AskForApproval::Never,
64+
approvals_reviewer: ApprovalsReviewer::User,
65+
permission_profile: turn_context.permission_profile(),
66+
active_permission_profile: None,
67+
cwd: turn_context.config.cwd.clone(),
68+
reasoning_effort: None,
69+
initial_messages: None,
70+
network_proxy: None,
71+
rollout_path: None,
72+
}),
73+
/*rollout_path*/ None,
74+
SessionSource::Cli,
75+
);
76+
thread.submit(Op::Interrupt).await.expect("fill the queue");
77+
78+
let interrupt = thread.interrupt_if_no_pending_input(&turn_context.sub_id);
79+
tokio::pin!(interrupt);
80+
assert!(futures::poll!(interrupt.as_mut()).is_pending());
81+
// The pending submission still owns its reply sender when cancellation wins.
82+
cancellation_token.cancel();
83+
assert_eq!(
84+
tokio::time::timeout(Duration::from_secs(/*secs*/ 1), interrupt)
85+
.await
86+
.expect("cancellation should unblock the submission")
87+
.expect("cancelling a submission does not mean the agent died"),
88+
false,
89+
);
90+
assert!(!rx_sub.is_closed());
91+
assert_eq!(rx_sub.len(), 1);
92+
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
93+
}

‎codex-rs/core/src/session/handlers.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,11 @@ pub(super) async fn submission_loop(
428428
interrupt(&sess).await;
429429
false
430430
}
431+
Op::InterruptIfNoPendingInput { turn_id, reply } => {
432+
sess.interrupt_turn_if_no_pending_input(&turn_id, reply)
433+
.await;
434+
false
435+
}
431436
Op::CleanBackgroundTerminals => {
432437
clean_background_terminals(&sess).await;
433438
false

‎codex-rs/core/src/session/input_queue.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ impl InputQueue {
333333
Some(active_turn) => {
334334
let turn_state = active_turn.turn_state.lock().await;
335335
(
336-
!turn_state.pending_input.items.is_empty(),
336+
!turn_state.pending_input.is_empty(),
337337
turn_state.accepts_mailbox_delivery_for_current_turn(),
338338
)
339339
}
@@ -351,6 +351,10 @@ impl InputQueue {
351351
}
352352

353353
impl TurnInputQueue {
354+
pub(crate) fn is_empty(&self) -> bool {
355+
self.items.is_empty()
356+
}
357+
354358
fn has_pending_input(&self) -> bool {
355359
self.items.iter().any(|input| {
356360
matches!(

‎codex-rs/core/src/tasks/mod.rs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -586,10 +586,19 @@ impl Session {
586586
None
587587
}
588588
};
589-
let Some(mut active_turn) = active_turn else {
589+
let Some(active_turn) = active_turn else {
590590
return false;
591591
};
592592

593+
self.finish_turn_abort(active_turn, reason).await;
594+
true
595+
}
596+
597+
pub(crate) async fn finish_turn_abort(
598+
self: &Arc<Self>,
599+
mut active_turn: ActiveTurn,
600+
reason: TurnAbortReason,
601+
) {
593602
let task = active_turn.task.take();
594603
let turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context));
595604
if let Some(task) = task {
@@ -607,8 +616,6 @@ impl Session {
607616
if reason == TurnAbortReason::Interrupted {
608617
self.maybe_start_turn_for_pending_work().await;
609618
}
610-
611-
true
612619
}
613620

614621
pub async fn on_task_finished(

‎codex-rs/core/tests/suite/pending_input.rs‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,145 @@ async fn user_input_does_not_preempt_after_reasoning_item() {
11621162
server.shutdown().await;
11631163
}
11641164

1165+
#[derive(Clone, Copy)]
1166+
enum ConditionalInterruptCase {
1167+
CurrentTurn,
1168+
StaleTurn,
1169+
PendingUserInput,
1170+
PendingMailbox,
1171+
AbandonedRequest,
1172+
}
1173+
1174+
#[test_case(ConditionalInterruptCase::CurrentTurn; "current_turn_without_pending_input")]
1175+
#[test_case(ConditionalInterruptCase::StaleTurn; "stale_turn")]
1176+
#[test_case(ConditionalInterruptCase::PendingUserInput; "pending_user_input")]
1177+
#[test_case(ConditionalInterruptCase::PendingMailbox; "pending_mailbox")]
1178+
#[test_case(ConditionalInterruptCase::AbandonedRequest; "abandoned_request")]
1179+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1180+
async fn interrupt_if_no_pending_input_checks_turn_and_queue(
1181+
case: ConditionalInterruptCase,
1182+
) -> anyhow::Result<()> {
1183+
const INITIAL_PROMPT: &str = "first prompt";
1184+
const PENDING_PROMPT: &str = "preserve this pending input";
1185+
let (release_response, response_gate) = oneshot::channel();
1186+
let first_chunks = vec![
1187+
chunk(ev_response_created("resp-1")),
1188+
chunk(ev_reasoning_item_added("reason-1", &["thinking"])),
1189+
gated_chunk(
1190+
response_gate,
1191+
vec![
1192+
ev_reasoning_item("reason-1", &["thinking"], &[]),
1193+
ev_completed("resp-1"),
1194+
],
1195+
),
1196+
];
1197+
let (server, _completions) =
1198+
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
1199+
let config_server = responses::start_mock_server().await;
1200+
let base_url = format!("{}/v1", server.uri());
1201+
let test = test_codex()
1202+
.with_model("gpt-5.4")
1203+
.with_config(move |config| {
1204+
config.model_provider.base_url = Some(base_url);
1205+
let _ = config.features.disable(Feature::EnableRequestCompression);
1206+
})
1207+
.build_with_auto_env(&config_server)
1208+
.await?;
1209+
let codex = &test.codex;
1210+
let TurnInputSubmission::Started { turn_id } = codex
1211+
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
1212+
text: INITIAL_PROMPT.to_string(),
1213+
text_elements: Vec::new(),
1214+
}]))
1215+
.await?
1216+
else {
1217+
panic!("initial input should start a turn");
1218+
};
1219+
wait_for_reasoning_item_started(codex).await;
1220+
if matches!(case, ConditionalInterruptCase::PendingUserInput) {
1221+
steer_user_input(codex, PENDING_PROMPT).await;
1222+
}
1223+
if matches!(case, ConditionalInterruptCase::PendingMailbox) {
1224+
submit_queue_only_agent_mail(codex, PENDING_PROMPT).await;
1225+
}
1226+
let expected_turn_id = match case {
1227+
ConditionalInterruptCase::StaleTurn | ConditionalInterruptCase::AbandonedRequest => {
1228+
format!("stale-{turn_id}")
1229+
}
1230+
ConditionalInterruptCase::CurrentTurn
1231+
| ConditionalInterruptCase::PendingUserInput
1232+
| ConditionalInterruptCase::PendingMailbox => turn_id.clone(),
1233+
};
1234+
if matches!(case, ConditionalInterruptCase::AbandonedRequest) {
1235+
let (reply, result) = oneshot::channel();
1236+
drop(result);
1237+
codex
1238+
.submit(Op::InterruptIfNoPendingInput { turn_id, reply })
1239+
.await?;
1240+
// Wait for the abandoned request to be handled before releasing the response.
1241+
let (reply, result) = oneshot::channel();
1242+
codex
1243+
.submit(Op::InterruptIfNoPendingInput {
1244+
turn_id: expected_turn_id.clone(),
1245+
reply,
1246+
})
1247+
.await?;
1248+
assert!(!tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), result).await??);
1249+
}
1250+
let should_abort = matches!(case, ConditionalInterruptCase::CurrentTurn);
1251+
assert_eq!(
1252+
tokio::time::timeout(
1253+
std::time::Duration::from_secs(/*secs*/ 10),
1254+
codex.interrupt_if_no_pending_input(&expected_turn_id),
1255+
)
1256+
.await??,
1257+
should_abort,
1258+
);
1259+
1260+
if should_abort {
1261+
wait_for_event(codex, |event| {
1262+
assert!(!matches!(event, EventMsg::TurnComplete(_)));
1263+
matches!(event, EventMsg::TurnAborted(_))
1264+
})
1265+
.await;
1266+
let _ = release_response.send(());
1267+
} else {
1268+
release_response.send(()).expect("release model response");
1269+
wait_for_event(codex, |event| {
1270+
assert!(!matches!(event, EventMsg::TurnAborted(_)));
1271+
matches!(event, EventMsg::TurnComplete(_))
1272+
})
1273+
.await;
1274+
}
1275+
let requests = server.requests().await;
1276+
if matches!(case, ConditionalInterruptCase::PendingUserInput) {
1277+
assert_eq!(requests.len(), 2);
1278+
let second: Value = from_slice(&requests[1])?;
1279+
let prompts = message_input_texts(&second, "user")
1280+
.into_iter()
1281+
.filter(|text| text == INITIAL_PROMPT || text == PENDING_PROMPT)
1282+
.collect::<Vec<_>>();
1283+
assert_eq!(prompts, vec![INITIAL_PROMPT, PENDING_PROMPT]);
1284+
} else if matches!(case, ConditionalInterruptCase::PendingMailbox) {
1285+
assert_eq!(requests.len(), 2);
1286+
let second: Value = from_slice(&requests[1])?;
1287+
let mail = second["input"]
1288+
.as_array()
1289+
.expect("model input")
1290+
.iter()
1291+
.find(|item| item["type"] == "agent_message")
1292+
.expect("pending mailbox input");
1293+
assert_eq!(
1294+
mail["content"],
1295+
json!([{ "type": "input_text", "text": PENDING_PROMPT }])
1296+
);
1297+
} else {
1298+
assert_eq!(requests.len(), 1);
1299+
}
1300+
server.shutdown().await;
1301+
Ok(())
1302+
}
1303+
11651304
#[derive(Clone, Copy, PartialEq, Eq)]
11661305
enum CompactionFailurePoint {
11671306
PreTurn,

0 commit comments

Comments
 (0)