Files
codex/codex-rs/core/tests/suite/model_request.rs
T
liame-oai f276b82185 Allow upstream interruption when canceling Responses Lite turns (#57864)
## What changed

- Add the disabled-by-default `interrupt_on_cancel` feature to interrupt upstream inference when a Responses Lite turn is canceled, with a one-second cleanup deadline.
- Detach canceled WebSocket responses so replacement requests can open a new connection without waiting for cleanup.
- Add an optional `interrupt_on_cancel` override for thread samples, inheriting the parent setting when omitted.

## Testing

Add coverage for cancellation during inference and steering, replacement turns proceeding before cleanup finishes, and prompt cancellation of model response interceptors.

GitOrigin-RevId: 501368746fc04fd3fdbd45c1f34c75ce57f4e955
2026-10-29 00:03:28 +00:00

366 lines
13 KiB
Rust

//! Output gates allow inference immediately, hold complete output, and cancel with the turn.
use anyhow::Result;
use codex_core::TurnInputRequest;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::GuardianAssessment;
use codex_extension_api::ModelRequestContributor;
use codex_extension_api::ModelRequestInput;
use codex_extension_api::ModelRequestKind;
use codex_extension_api::ModelResponseInterceptor;
use codex_extension_api::ModelResponseStream;
use codex_extension_api::ResponseEvent;
use codex_features::Feature;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::responses::{self};
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::Duration;
use tokio::time::timeout;
#[derive(Debug)]
struct Gates {
delivered: Semaphore,
output_received: Semaphore,
output_waiting: Semaphore,
cancelled: Semaphore,
output_items: std::sync::atomic::AtomicUsize,
guardian_assessments: std::sync::Mutex<Vec<(GuardianAssessment, usize)>>,
metadata_event_types: std::sync::Mutex<Vec<&'static str>>,
}
#[derive(Debug)]
struct Contributor(Arc<Gates>);
impl ModelRequestContributor for Contributor {
fn request(&self, input: ModelRequestInput<'_>) -> Option<Box<dyn ModelResponseInterceptor>> {
input
.client_metadata
.get_or_insert_default()
.insert("test-interceptor".into(), "present".into());
if input.kind == ModelRequestKind::Warmup {
return None;
}
Some(Box::new(Permit {
gates: self.0.clone(),
released: false,
}))
}
}
struct Permit {
gates: Arc<Gates>,
released: bool,
}
impl ModelResponseInterceptor for Permit {
fn intercept(mut self: Box<Self>, mut stream: ModelResponseStream) -> ModelResponseStream {
Box::pin(
futures::stream::once(async move {
let mut events = Vec::new();
let mut output_items = 0;
while let Some(event) = stream.next().await {
let metadata_event_type = match &event {
Ok(ResponseEvent::ModelVerifications(_)) => Some("verification"),
Ok(ResponseEvent::TurnModerationMetadata(_)) => Some("moderation"),
Ok(ResponseEvent::SafetyBuffering(_)) => Some("buffering"),
Ok(ResponseEvent::GuardianAssessment(_)) => Some("guardian"),
_ => None,
};
if let Some(metadata_event_type) = metadata_event_type {
self.gates
.metadata_event_types
.lock()
.expect("metadata events mutex is not poisoned")
.push(metadata_event_type);
}
if let Ok(ResponseEvent::GuardianAssessment(assessment)) = &event {
self.gates
.guardian_assessments
.lock()
.expect("guardian assessments mutex is not poisoned")
.push((assessment.clone(), output_items));
}
if matches!(&event, Ok(ResponseEvent::OutputItemDone(_))) {
output_items += 1;
self.gates.output_received.add_permits(1);
}
let completed = matches!(&event, Ok(ResponseEvent::Completed { .. }));
events.push(event);
if completed {
self.gates
.output_items
.store(output_items, std::sync::atomic::Ordering::SeqCst);
self.gates.output_waiting.add_permits(1);
self.gates
.delivered
.acquire()
.await
.expect("delivery semaphore remains open")
.forget();
self.released = true;
break;
}
}
futures::stream::iter(events)
})
.flatten(),
)
}
}
impl Drop for Permit {
fn drop(&mut self) {
if !self.released {
self.gates.cancelled.add_permits(1);
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_request_gates_output_and_cancellation() -> Result<()> {
let server = responses::start_mock_server().await;
let response = sse(vec![
ev_response_created("r1"),
json!({
"type": "response.metadata", "response_id": "r1",
"metadata": {"guardian": {"item_id": "m1", "requires_review": false}}
}),
ev_assistant_message("m1", "first"),
ev_assistant_message("m2", "done"),
ev_completed("r1"),
]);
let model = responses::mount_sse_sequence(&server, vec![response.clone(), response]).await;
let gates = Arc::new(Gates {
delivered: Semaphore::new(0),
output_received: Semaphore::new(0),
output_waiting: Semaphore::new(0),
cancelled: Semaphore::new(0),
output_items: std::sync::atomic::AtomicUsize::new(0),
guardian_assessments: std::sync::Mutex::new(Vec::new()),
metadata_event_types: std::sync::Mutex::new(Vec::new()),
});
let mut registry = ExtensionRegistryBuilder::new();
registry.model_request_contributor(Arc::new(Contributor(gates.clone())));
let mut builder = test_codex().with_extensions(Arc::new(registry.build()));
let test = builder.build_with_auto_env(&server).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello".into(),
text_elements: vec![],
}]))
.await?;
timeout(Duration::from_secs(10), gates.output_waiting.acquire())
.await??
.forget();
assert_eq!(model.requests().len(), 1);
assert_eq!(
*gates.guardian_assessments.lock().unwrap(),
vec![(
GuardianAssessment {
response_id: "r1".into(),
item_id: "m1".into(),
requires_review: false,
},
0
)]
);
assert_eq!(
gates.output_items.load(std::sync::atomic::Ordering::SeqCst),
2
);
assert_eq!(
model.single_request().body_json()["client_metadata"]["test-interceptor"],
"present"
);
// Drain earlier events and prove no assistant output passes a closed delivery gate.
assert!(
timeout(
Duration::from_millis(100),
wait_for_event(&test.codex, |e| matches!(
e,
EventMsg::AgentMessage(_) | EventMsg::TurnComplete(_)
))
)
.await
.is_err()
);
gates.delivered.add_permits(1);
wait_for_event(&test.codex, |e| matches!(e, EventMsg::TurnComplete(_))).await;
assert_eq!(gates.cancelled.available_permits(), 0);
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "again".into(),
text_elements: vec![],
}]))
.await?;
timeout(Duration::from_secs(10), gates.output_waiting.acquire())
.await??
.forget();
test.codex.submit(Op::Interrupt).await?;
timeout(Duration::from_secs(10), gates.cancelled.acquire())
.await??
.forget();
assert_eq!(model.requests().len(), 2);
assert!(
!model.requests()[1]
.body_json()
.to_string()
.contains("requires_review")
);
Ok(())
}
#[tokio::test]
async fn model_request_gates_websocket_output_but_not_warmup() -> Result<()> {
let completed = ev_completed("response");
let server = responses::start_websocket_server(vec![vec![
vec![ev_response_created("warm"), ev_completed("warm")],
vec![
ev_response_created("response"),
json!({
"type": "response.metadata", "response_id": "response",
"metadata": {
"guardian": {"item_id": "message", "requires_review": true},
"openai_verification_recommendation": ["trusted_access_for_cyber"],
"openai_chatgpt_moderation_metadata": {"presentation": "inline"},
"type": "safety_buffering", "use_cases": ["cyber"],
"reasons": ["user_risk"], "future_metadata": true
}
}),
json!({
"type": "response.metadata", "response_id": "response",
"metadata": {
"guardian": {"item_id": "other", "requires_review": "false"},
"openai_verification_recommendation": ["trusted_access_for_cyber"],
"openai_chatgpt_moderation_metadata": {"presentation": "inline"},
"type": "safety_buffering", "use_cases": ["cyber"],
"reasons": ["user_risk"]
}
}),
ev_assistant_message("message", "done"),
],
vec![
serde_json::json!({"type": "response.interrupt.accepted", "response_id": "response"}),
completed.clone(),
],
vec![],
]])
.await;
let release_completion = server.gate_response_event(&completed);
let gates = Arc::new(Gates {
delivered: Semaphore::new(0),
output_received: Semaphore::new(0),
output_waiting: Semaphore::new(0),
cancelled: Semaphore::new(0),
output_items: std::sync::atomic::AtomicUsize::new(0),
guardian_assessments: std::sync::Mutex::new(Vec::new()),
metadata_event_types: std::sync::Mutex::new(Vec::new()),
});
let mut registry = ExtensionRegistryBuilder::new();
registry.model_request_contributor(Arc::new(Contributor(gates.clone())));
let mut builder = test_codex()
.with_model("gpt-5.2")
.with_model_info_override("gpt-5.2", |model_info| {
model_info.use_responses_lite = true;
})
.with_config(|config| {
config
.features
.enable(Feature::InterruptOnCancel)
.expect("enable InterruptOnCancel feature");
})
.with_extensions(Arc::new(registry.build()));
let test = builder.build_with_websocket_server(&server).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello".into(),
text_elements: vec![],
}]))
.await?;
timeout(Duration::from_secs(10), gates.output_received.acquire())
.await??
.forget();
let requests = server.single_connection();
assert_eq!(
*gates
.metadata_event_types
.lock()
.expect("metadata events mutex is not poisoned"),
vec![
"verification",
"moderation",
"buffering",
"guardian",
"verification",
"moderation",
"buffering",
]
);
assert_eq!(
*gates.guardian_assessments.lock().unwrap(),
vec![(
GuardianAssessment {
response_id: "response".into(),
item_id: "message".into(),
requires_review: true,
},
0
)]
);
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].body_json()["generate"], false);
assert_eq!(
requests[0].body_json()["client_metadata"]["test-interceptor"],
"present"
);
assert_eq!(
requests[1].body_json()["client_metadata"]["test-interceptor"],
"present"
);
// Neither a terminal response nor the cleanup deadline may release this interceptor.
tokio::time::pause();
{
let cancelled = async {
test.codex.submit(Op::Interrupt).await?;
timeout(Duration::from_secs(10), gates.cancelled.acquire())
.await??
.forget();
server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 2)
.await;
anyhow::Ok(())
};
tokio::pin!(cancelled);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
anyhow::ensure!(
std::time::Instant::now() < deadline,
"interceptor remained alive during cancelled response cleanup"
);
tokio::select! {
result = &mut cancelled => { result?; break; }
// Keep the runtime runnable so paused time cannot auto-advance during I/O.
() = tokio::task::yield_now() => {}
}
}
}
tokio::time::resume();
release_completion
.send(())
.expect("release interrupted completion");
assert!(
server
.wait_for_closed_connections(/*expected*/ 1, Duration::from_secs(5))
.await
);
server.shutdown().await;
Ok(())
}