From b3f11d668aa7e904c553f77359977ab84ad06d46 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 4 Mar 2026 20:33:30 -0800 Subject: [PATCH] Suppress cancelled turn follow-up requests --- .../tests/suite/v2/thread_unsubscribe.rs | 12 ++++ codex-rs/core/src/codex.rs | 10 ++++ codex-rs/core/tests/suite/abort_tasks.rs | 60 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index f16ef8bb06..2f4ce15939 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -168,6 +168,18 @@ async fn thread_unsubscribe_during_turn_interrupts_turn_and_emits_thread_closed( }; assert_eq!(payload.thread_id, thread_id); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + assert_eq!( + requests.len(), + 1, + "unsubscribe should not trigger a follow-up responses request" + ); + Ok(()) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 48869483ff..ca2648f87e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6583,6 +6583,16 @@ async fn try_run_sampling_request( drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; + if cancellation_token.is_cancelled() + && outcome.as_ref().is_ok_and(|result| result.needs_follow_up) + { + debug!( + turn_id = %turn_context.sub_id, + "turn cancelled after tool drain; suppressing follow-up request" + ); + return Err(CodexErr::TurnAborted); + } + if should_emit_turn_diff { let unified_diff = { let mut tracker = turn_diff_tracker.lock().await; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index af3c70b74e..3445b0ef56 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -239,3 +239,63 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { "expected marker in follow-up request" ); } + +/// Interrupting a turn while a tool-produced follow-up is pending must not +/// start another model request before the session reports TurnAborted. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_does_not_issue_follow_up_request() { + let command = "sleep 60"; + let call_id = "call-no-follow-up"; + + let args = json!({ + "command": command, + "timeout_ms": 60_000 + }) + .to_string(); + let first_body = sse(vec![ + ev_response_created("resp-no-follow-up"), + ev_function_call(call_id, "shell_command", &args), + ev_completed("resp-no-follow-up"), + ]); + + let server = start_mock_server().await; + let response_mock = mount_sse_once(&server, first_body).await; + + let fixture = test_codex() + .with_model("gpt-5.1") + .build(&server) + .await + .unwrap(); + let codex = Arc::clone(&fixture.codex); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start interrupt follow-up guard".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; + + tokio::time::sleep(Duration::from_secs_f32(0.1)).await; + codex.submit(Op::Interrupt).await.unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; + + tokio::time::sleep(Duration::from_millis(200)).await; + + let requests = response_mock.requests(); + assert_eq!( + requests.len(), + 1, + "interrupt should not issue a follow-up responses request" + ); + assert!( + response_mock.saw_function_call(call_id), + "expected initial function call to be recorded" + ); +}