mirror of
https://github.com/openai/codex.git
synced 2026-09-30 17:28:17 +08:00
Preempt model responses when new user input arrives (#48141)
## Why With `instant_interrupt` enabled, new user input should reach the model without waiting for an unfinished response or stream retry backoff. ## What changed - Interrupt request setup, response streaming, and retry backoff when user input arrives, then continue the turn with the queued input. - Reset the WebSocket connection and continuation state after preemption so the next request sends full history. - Drain pending input after a preempted step even when compaction leaves the estimated context above the token limit. ## Testing Add regression coverage for WebSocket reconnection with full history, interrupted retry backoff, input queued during compaction, and yielding running code-mode calls. Add a streaming scenario that preserves completed commentary while excluding unfinished assistant text from the replacement request. GitOrigin-RevId: b7e42b6afffbf87da15af05e0850af8579d1353f
This commit is contained in:
@@ -2265,6 +2265,11 @@ impl ModelClientSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the cached WebSocket connection and its continuation state.
|
||||
pub(crate) fn drop_connection(&mut self) {
|
||||
self.websocket_session.reset(Some("other"));
|
||||
}
|
||||
|
||||
/// Permanently disables WebSockets for this Codex session and resets WebSocket state.
|
||||
///
|
||||
/// This is used after exhausting the provider retry budget, to force subsequent requests onto
|
||||
|
||||
@@ -20,7 +20,7 @@ use tokio_util::sync::CancellationToken;
|
||||
/// Request-scoped state that may change between model sampling requests.
|
||||
pub(crate) struct StepContext {
|
||||
pub(crate) turn: Arc<TurnContext>,
|
||||
/// Yields this request's code-mode observations when a user message arrives.
|
||||
/// Preempts this request and yields its code-mode observations when user input arrives.
|
||||
pub(crate) preempt: Option<CancellationToken>,
|
||||
/// Realtime call activity and instructions captured for this sampling request.
|
||||
pub(crate) realtime: RealtimeConversationSnapshot,
|
||||
|
||||
@@ -416,7 +416,7 @@ pub(crate) async fn run_turn(
|
||||
// Pending input is drained into history before building the next model request.
|
||||
// However, we defer that drain until after sampling in two cases:
|
||||
// 1. At the start of a turn, so the fresh turn input in `input` gets sampled first.
|
||||
// 2. After auto-compact, when model/tool continuation needs to resume before any steer.
|
||||
// 2. After auto-compact, while a model/tool continuation is pending.
|
||||
|
||||
let mut next_step_context = Some(first_step_context);
|
||||
let mut guardian_budget_compacted = false;
|
||||
@@ -633,7 +633,13 @@ pub(crate) async fn run_turn(
|
||||
if run_pending_session_start_hooks(&sess, &turn_context).await {
|
||||
return Ok(None);
|
||||
}
|
||||
can_drain_pending_input = !model_needs_follow_up;
|
||||
// After a signaled step, drain the steer even if compaction
|
||||
// leaves the estimated context above the token limit.
|
||||
can_drain_pending_input = !model_needs_follow_up
|
||||
|| step_context
|
||||
.preempt
|
||||
.as_ref()
|
||||
.is_some_and(CancellationToken::is_cancelled);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1588,6 +1594,7 @@ async fn run_sampling_request(
|
||||
cancellation_token: CancellationToken,
|
||||
) -> CodexResult<(SamplingRequestResult, Vec<ResponseItem>)> {
|
||||
let turn_context = Arc::clone(&step_context.turn);
|
||||
let preempt = step_context.preempt.clone().unwrap_or_default();
|
||||
let _input_watch = if let Some(preempt) = &step_context.preempt {
|
||||
sess.input_queue
|
||||
.watch_user_input(&sess.active_turn, &turn_context.sub_id, preempt.clone())
|
||||
@@ -1675,11 +1682,9 @@ async fn run_sampling_request(
|
||||
},
|
||||
};
|
||||
|
||||
if original_input.is_none() {
|
||||
original_input = Some(prompt.input);
|
||||
}
|
||||
let original_input = original_input.get_or_insert(prompt.input);
|
||||
|
||||
handle_response_stream_error(
|
||||
let retry = handle_response_stream_error(
|
||||
&mut retry_state,
|
||||
max_retries,
|
||||
err,
|
||||
@@ -1688,7 +1693,22 @@ async fn run_sampling_request(
|
||||
&turn_context,
|
||||
ResponsesStreamRequest::Sampling,
|
||||
)
|
||||
.or_cancel(&preempt)
|
||||
.or_cancel(&cancellation_token)
|
||||
.await?;
|
||||
if cancellation_token.is_cancelled() {
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
if preempt.is_cancelled() {
|
||||
return Ok((
|
||||
SamplingRequestResult {
|
||||
needs_follow_up: true,
|
||||
last_agent_message: None,
|
||||
},
|
||||
std::mem::take(original_input),
|
||||
));
|
||||
}
|
||||
retry??;
|
||||
turn_context.turn_timing_state.record_sampling_retry();
|
||||
}
|
||||
}
|
||||
@@ -2507,24 +2527,37 @@ async fn try_run_sampling_request(
|
||||
.features
|
||||
.enabled(Feature::ConcurrentReasoningSummaries)
|
||||
&& turn_context.provider.info().is_openai();
|
||||
let mut stream = client_session
|
||||
let preempt = step_context.preempt.clone().unwrap_or_default();
|
||||
let effort = sess
|
||||
.reasoning_effort_for_request(&step_context.settings, super::RequestEffortUsage::Sampling)
|
||||
.await;
|
||||
let stream = client_session
|
||||
.stream(
|
||||
prompt,
|
||||
&step_context.settings.model_info,
|
||||
&step_context.session_telemetry,
|
||||
sess.reasoning_effort_for_request(
|
||||
&step_context.settings,
|
||||
super::RequestEffortUsage::Sampling,
|
||||
)
|
||||
.await,
|
||||
effort,
|
||||
step_context.settings.reasoning_summary,
|
||||
step_context.settings.service_tier.clone(),
|
||||
responses_metadata,
|
||||
&inference_trace,
|
||||
)
|
||||
.instrument(trace_span!("stream_request"))
|
||||
.or_cancel(&preempt)
|
||||
.or_cancel(&cancellation_token)
|
||||
.await??;
|
||||
.await?;
|
||||
if cancellation_token.is_cancelled() {
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
if preempt.is_cancelled() {
|
||||
drop(stream);
|
||||
client_session.drop_connection();
|
||||
return Ok(SamplingRequestResult {
|
||||
needs_follow_up: true,
|
||||
last_agent_message: None,
|
||||
});
|
||||
}
|
||||
let mut stream = stream??;
|
||||
let mut in_flight: FuturesOrdered<InFlightFuture<'static>> = FuturesOrdered::new();
|
||||
let mut needs_follow_up = false;
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
@@ -2570,16 +2603,27 @@ async fn try_run_sampling_request(
|
||||
codex.usage.total_tokens = field::Empty,
|
||||
);
|
||||
|
||||
let event = match stream
|
||||
let event = stream
|
||||
.next()
|
||||
.instrument(trace_span!(parent: &handle_responses, "receiving"))
|
||||
.or_cancel(&preempt)
|
||||
.or_cancel(&cancellation_token)
|
||||
.await
|
||||
{
|
||||
Ok(event) => event,
|
||||
Err(codex_async_utils::CancelErr::Cancelled) => {
|
||||
break Err(CodexErr::TurnAborted);
|
||||
.await;
|
||||
if cancellation_token.is_cancelled() {
|
||||
break Err(CodexErr::TurnAborted);
|
||||
}
|
||||
let event = match event {
|
||||
Ok(Ok(event)) => event,
|
||||
Ok(Err(_)) => {
|
||||
// TODO: Reconcile any response item already being presented to the client.
|
||||
drop(stream);
|
||||
client_session.drop_connection();
|
||||
break Ok(SamplingRequestResult {
|
||||
needs_follow_up: true,
|
||||
last_agent_message,
|
||||
});
|
||||
}
|
||||
Err(_) => break Err(CodexErr::TurnAborted),
|
||||
};
|
||||
|
||||
let event = match event {
|
||||
|
||||
@@ -64,7 +64,6 @@ use test_case::test_case;
|
||||
use tokio::sync::oneshot;
|
||||
use wiremock::Mock;
|
||||
use wiremock::Request;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path_regex;
|
||||
|
||||
@@ -439,6 +438,14 @@ async fn wait_for_turn_complete(codex: &CodexThread) {
|
||||
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
}
|
||||
|
||||
async fn wait_for_successful_turn(codex: &CodexThread) {
|
||||
let event = wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
assert!(
|
||||
matches!(&event, EventMsg::TurnComplete(completed) if completed.error.is_none()),
|
||||
"turn failed: {event:?}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_sleep_item_started(codex: &CodexThread, call_id: &str, duration_ms: u64) {
|
||||
let event = wait_for_event(codex, |event| {
|
||||
matches!(
|
||||
@@ -1124,6 +1131,129 @@ async fn injected_response_item_reopens_turn_after_final_answer() {
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn steer_reconnects_websocket_and_sends_full_history() -> anyhow::Result<()> {
|
||||
core_test_support::skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_websocket_server(vec![
|
||||
vec![
|
||||
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
||||
vec![
|
||||
ev_response_created("resp-interrupted"),
|
||||
ev_reasoning_item_added("reason-1", &["thinking"]),
|
||||
],
|
||||
// Keep the first connection open until the client closes it.
|
||||
vec![],
|
||||
],
|
||||
vec![vec![
|
||||
ev_response_created("resp-follow-up"),
|
||||
ev_completed("resp-follow-up"),
|
||||
]],
|
||||
])
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_model("gpt-5.4")
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::InstantInterrupt)
|
||||
.expect("enable InstantInterrupt feature");
|
||||
})
|
||||
.build_with_websocket_server(&server)
|
||||
.await?;
|
||||
let codex = &test.codex;
|
||||
|
||||
submit_user_input(codex, "first prompt").await;
|
||||
wait_for_reasoning_item_started(codex).await;
|
||||
let initial_request = server
|
||||
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 1)
|
||||
.await
|
||||
.body_json();
|
||||
assert_eq!(initial_request["previous_response_id"], "warm-1");
|
||||
|
||||
steer_user_input(codex, "second prompt").await;
|
||||
let follow_up = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0),
|
||||
)
|
||||
.await
|
||||
.expect("steer should close the first socket and open a new one")
|
||||
.body_json();
|
||||
assert!(follow_up.get("previous_response_id").is_none());
|
||||
let prompts = message_input_texts(&follow_up, "user")
|
||||
.into_iter()
|
||||
.filter(|text| text == "first prompt" || text == "second prompt")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(prompts, vec!["first prompt", "second prompt"]);
|
||||
wait_for_event(codex, |event| {
|
||||
assert!(!matches!(
|
||||
event,
|
||||
EventMsg::TurnAborted(_) | EventMsg::StreamError(_)
|
||||
));
|
||||
matches!(event, EventMsg::TurnComplete(completed) if completed.error.is_none())
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(server.connections().len(), 2);
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn steer_during_stream_retry_skips_backoff() {
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse_failed(
|
||||
"resp-failed",
|
||||
"rate_limit_exceeded",
|
||||
"Rate limit exceeded. Please try again in 60s.",
|
||||
),
|
||||
responses::sse_completed("resp-follow-up"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_model("gpt-5.4")
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::InstantInterrupt)
|
||||
.expect("enable InstantInterrupt feature");
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(1);
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await
|
||||
.expect("build Codex test session");
|
||||
let codex = &test.codex;
|
||||
|
||||
submit_user_input(codex, "first prompt").await;
|
||||
wait_for_event(codex, |event| matches!(event, EventMsg::StreamError(_))).await;
|
||||
steer_user_input(codex, "second prompt").await;
|
||||
// Completion must not wait for the server's 60-second retry delay.
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
wait_for_successful_turn(codex),
|
||||
)
|
||||
.await
|
||||
.expect("steer should interrupt retry backoff");
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let second = requests[1].body_json();
|
||||
let prompts = message_input_texts(&second, "user")
|
||||
.into_iter()
|
||||
.filter(|text| text == "first prompt" || text == "second prompt")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(prompts, vec!["first prompt", "second prompt"]);
|
||||
codex
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
.expect("shut down test session");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn steers_during_tool_drain_preserve_tool_output_and_each_input() {
|
||||
const TOOL_CALL_ID: &str = "held-tool";
|
||||
@@ -1171,18 +1301,6 @@ async fn steers_during_tool_drain_preserve_tool_output_and_each_input() {
|
||||
|
||||
steer_user_input(&codex, "second prompt").await;
|
||||
steer_user_input(&codex, "third prompt").await;
|
||||
release_response
|
||||
.send(())
|
||||
.expect("release the model response");
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(200),
|
||||
server.wait_for_request_count(/*count*/ 2),
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"the replacement request must wait for the direct tool"
|
||||
);
|
||||
codex
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: tool_request.call_id,
|
||||
@@ -1233,6 +1351,8 @@ async fn steers_during_tool_drain_preserve_tool_output_and_each_input() {
|
||||
"the direct tool result should be recorded exactly once"
|
||||
);
|
||||
|
||||
// The replacement request completed while the original response was still gated.
|
||||
drop(release_response);
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -1276,6 +1396,10 @@ async fn steers_yield_exec_and_wait_without_stopping_the_cell(instant_interrupt:
|
||||
.build_with_auto_env(&server)
|
||||
.await
|
||||
.expect("build Codex test session");
|
||||
test.codex
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
.expect("shut down unused test session");
|
||||
let codex = test
|
||||
.thread_manager
|
||||
.start_thread(StartThreadOptions {
|
||||
@@ -1305,73 +1429,36 @@ async fn steers_yield_exec_and_wait_without_stopping_the_cell(instant_interrupt:
|
||||
)
|
||||
.await;
|
||||
|
||||
// The cell ID comes from the real yielded exec result, so do not predict a
|
||||
// runtime generation or rely on a fixed ID when issuing subsequent waits.
|
||||
let cell_id = Arc::new(std::sync::Mutex::new(None::<String>));
|
||||
if instant_interrupt {
|
||||
let cell_id_for_first_wait = Arc::clone(&cell_id);
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(".*/responses$"))
|
||||
.respond_with(move |request: &Request| {
|
||||
let body: Value = from_slice(&request.body).expect("parse replacement request");
|
||||
let output = call_output_text(&body, "custom_tool_call_output", EXEC_ID)
|
||||
.expect("yielded exec output");
|
||||
let call_items = body["input"]
|
||||
.as_array()
|
||||
.expect("request input is an array")
|
||||
.iter()
|
||||
.filter(|item| item.get("call_id").is_some())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let id = output
|
||||
.strip_prefix("Script running with cell ID ")
|
||||
.and_then(|rest| rest.lines().next())
|
||||
.unwrap_or_else(|| {
|
||||
panic!("missing running cell ID in exec output: {output:?}; call items: {call_items:?}")
|
||||
})
|
||||
.to_string();
|
||||
*cell_id_for_first_wait.lock().expect("lock cell ID") = Some(id.clone());
|
||||
ResponseTemplate::new(200).set_body_raw(
|
||||
responses::sse(vec![
|
||||
ev_response_created("resp-wait-one"),
|
||||
for (response_id, call_id) in [
|
||||
("resp-wait-one", WAIT_ONE_ID),
|
||||
("resp-wait-two", WAIT_TWO_ID),
|
||||
] {
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(".*/responses$"))
|
||||
.respond_with(move |request: &Request| {
|
||||
let body: Value = from_slice(&request.body).expect("parse replacement request");
|
||||
let output = call_output_text(&body, "custom_tool_call_output", EXEC_ID)
|
||||
.expect("yielded exec output");
|
||||
// Every follow-up includes the real cell ID in the exec output.
|
||||
let id = output
|
||||
.strip_prefix("Script running with cell ID ")
|
||||
.and_then(|rest| rest.lines().next())
|
||||
.expect("running cell ID");
|
||||
responses::sse_response(responses::sse(vec![
|
||||
ev_response_created(response_id),
|
||||
ev_function_call(
|
||||
WAIT_ONE_ID,
|
||||
call_id,
|
||||
"wait",
|
||||
&json!({"cell_id": id, "yield_time_ms": 60000}).to_string(),
|
||||
),
|
||||
ev_completed("resp-wait-one"),
|
||||
]),
|
||||
"text/event-stream",
|
||||
)
|
||||
})
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let cell_id_for_second_wait = Arc::clone(&cell_id);
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(".*/responses$"))
|
||||
.respond_with(move |_: &Request| {
|
||||
let id = cell_id_for_second_wait
|
||||
.lock()
|
||||
.expect("lock cell ID")
|
||||
.clone()
|
||||
.expect("yielded cell ID");
|
||||
ResponseTemplate::new(200).set_body_raw(
|
||||
responses::sse(vec![
|
||||
ev_response_created("resp-wait-two"),
|
||||
ev_function_call(
|
||||
WAIT_TWO_ID,
|
||||
"wait",
|
||||
&json!({"cell_id": id, "yield_time_ms": 60000}).to_string(),
|
||||
),
|
||||
ev_completed("resp-wait-two"),
|
||||
]),
|
||||
"text/event-stream",
|
||||
)
|
||||
})
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
ev_completed(response_id),
|
||||
]))
|
||||
})
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let final_response =
|
||||
responses::mount_sse_once(&server, responses::sse_completed("resp-done")).await;
|
||||
@@ -1385,11 +1472,6 @@ async fn steers_yield_exec_and_wait_without_stopping_the_cell(instant_interrupt:
|
||||
};
|
||||
steer_user_input(&codex, "interrupt exec").await;
|
||||
if !instant_interrupt {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert!(
|
||||
final_response.requests().is_empty(),
|
||||
"a disabled interrupt must leave exec waiting for the nested tool"
|
||||
);
|
||||
codex
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: tool_request.call_id,
|
||||
@@ -1410,6 +1492,10 @@ async fn steers_yield_exec_and_wait_without_stopping_the_cell(instant_interrupt:
|
||||
.contains("background result")
|
||||
);
|
||||
assert!(message_input_texts(&request, "user").contains(&"interrupt exec".to_string()));
|
||||
codex
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
.expect("shut down test session");
|
||||
return;
|
||||
}
|
||||
wait_for_event(&codex, |event| {
|
||||
@@ -1443,16 +1529,14 @@ async fn steers_yield_exec_and_wait_without_stopping_the_cell(instant_interrupt:
|
||||
.await;
|
||||
|
||||
let request = final_response.single_request().body_json();
|
||||
let id = cell_id
|
||||
.lock()
|
||||
.expect("lock cell ID")
|
||||
.clone()
|
||||
.expect("yielded cell ID");
|
||||
let exec_output =
|
||||
call_output_text(&request, "custom_tool_call_output", EXEC_ID).expect("exec output");
|
||||
let id = exec_output
|
||||
.strip_prefix("Script running with cell ID ")
|
||||
.and_then(|rest| rest.lines().next())
|
||||
.expect("running cell ID");
|
||||
let wait_output =
|
||||
call_output_text(&request, "function_call_output", WAIT_ONE_ID).expect("first wait output");
|
||||
assert!(exec_output.contains(&format!("Script running with cell ID {id}")));
|
||||
assert!(wait_output.contains(&format!("Script running with cell ID {id}")));
|
||||
let completed_output = call_output_text(&request, "function_call_output", WAIT_TWO_ID)
|
||||
.expect("second wait output");
|
||||
@@ -1473,19 +1557,23 @@ async fn steers_yield_exec_and_wait_without_stopping_the_cell(instant_interrupt:
|
||||
prompts,
|
||||
vec!["start the cell", "interrupt exec", "interrupt wait"]
|
||||
);
|
||||
codex
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
.expect("shut down test session");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn steer_deferred_during_compaction_yields_code_mode() {
|
||||
const EXEC_ID: &str = "post-compact-exec";
|
||||
async fn steer_during_compaction_is_sent_after_compaction() {
|
||||
let (release_compact, compact_gate) = oneshot::channel();
|
||||
let (release_continuation, continuation_gate) = oneshot::channel();
|
||||
let (release_follow_up, follow_up_gate) = oneshot::channel();
|
||||
let (server, _) = start_streaming_sse_server(vec![
|
||||
vec![
|
||||
chunk(ev_response_created("resp-first")),
|
||||
chunk(ev_custom_tool_call("initial-exec", "exec", "text('ready');")),
|
||||
chunk(ev_completed_with_tokens("resp-first", /*total_tokens*/ 500)),
|
||||
chunk(ev_function_call("call-1", "test_tool", "{}")),
|
||||
chunk(ev_completed_with_tokens(
|
||||
"resp-first",
|
||||
/*total_tokens*/ 500,
|
||||
)),
|
||||
],
|
||||
vec![
|
||||
chunk(ev_response_created("resp-compact")),
|
||||
@@ -1497,26 +1585,13 @@ async fn steer_deferred_during_compaction_yields_code_mode() {
|
||||
],
|
||||
),
|
||||
],
|
||||
vec![
|
||||
chunk(ev_response_created("resp-continuation")),
|
||||
chunk(ev_custom_tool_call(
|
||||
EXEC_ID,
|
||||
"exec",
|
||||
"// @exec: {\"yield_time_ms\": 60000}\nawait tools.held_tool({}); text('finished');",
|
||||
)),
|
||||
gated_chunk(
|
||||
continuation_gate,
|
||||
vec![ev_completed_with_tokens("resp-continuation", /*total_tokens*/ 60)],
|
||||
),
|
||||
],
|
||||
vec![
|
||||
chunk(ev_response_created("resp-follow-up")),
|
||||
gated_chunk(follow_up_gate, vec![ev_completed("resp-follow-up")]),
|
||||
],
|
||||
response_completed_chunks("resp-follow-up"),
|
||||
response_completed_chunks("resp-extra-compaction"),
|
||||
response_completed_chunks("resp-steered"),
|
||||
])
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_model("test-gpt-5.1-codex")
|
||||
.with_model("gpt-5.4")
|
||||
.with_config(|config| {
|
||||
config.model_provider.name = "OpenAI (test)".into();
|
||||
config.model_provider.supports_websockets = false;
|
||||
@@ -1525,15 +1600,6 @@ async fn steer_deferred_during_compaction_yields_code_mode() {
|
||||
.features
|
||||
.enable(Feature::InstantInterrupt)
|
||||
.expect("enable InstantInterrupt feature");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodeMode)
|
||||
.expect("enable CodeMode feature");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodeModeHost)
|
||||
.expect("enable CodeModeHost feature");
|
||||
config.code_mode.disable_in_process_fallback = true;
|
||||
config
|
||||
.features
|
||||
.disable(Feature::EnableRequestCompression)
|
||||
@@ -1542,20 +1608,7 @@ async fn steer_deferred_during_compaction_yields_code_mode() {
|
||||
.build_with_streaming_server(&server)
|
||||
.await
|
||||
.expect("build Codex test session");
|
||||
let codex = test
|
||||
.thread_manager
|
||||
.start_thread(StartThreadOptions {
|
||||
dynamic_tools: vec![DynamicToolSpec::Function(DynamicToolFunctionSpec {
|
||||
name: "held_tool".into(),
|
||||
description: "A tool held until the test responds.".into(),
|
||||
input_schema: json!({"type": "object", "properties": {}}),
|
||||
defer_loading: false,
|
||||
})],
|
||||
..StartThreadOptions::new(test.config.clone())
|
||||
})
|
||||
.await
|
||||
.expect("start thread with dynamic tool")
|
||||
.thread;
|
||||
let codex = test.codex.clone();
|
||||
|
||||
submit_user_input(&codex, "first prompt").await;
|
||||
tokio::time::timeout(
|
||||
@@ -1566,54 +1619,28 @@ async fn steer_deferred_during_compaction_yields_code_mode() {
|
||||
.expect("compaction request should begin");
|
||||
steer_user_input(&codex, "old steer").await;
|
||||
release_compact.send(()).expect("finish compaction");
|
||||
let EventMsg::DynamicToolCallRequest(held_tool) = wait_for_event(&codex, |event| {
|
||||
matches!(event, EventMsg::DynamicToolCallRequest(request) if request.tool == "held_tool")
|
||||
})
|
||||
.await else {
|
||||
unreachable!("predicate guarantees a dynamic tool request");
|
||||
};
|
||||
release_continuation
|
||||
.send(())
|
||||
.expect("finish continuation response");
|
||||
wait_for_event(&codex, |event| {
|
||||
matches!(event, EventMsg::RawResponseCompleted(completed) if completed.response_id == "resp-continuation")
|
||||
})
|
||||
.await;
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
server.wait_for_request_count(/*count*/ 4),
|
||||
wait_for_successful_turn(&codex),
|
||||
)
|
||||
.await
|
||||
.expect("the deferred steer should yield the exec");
|
||||
let requests = server.requests().await;
|
||||
let continuation: Value = from_slice(&requests[2]).expect("parse continuation request");
|
||||
assert!(!message_input_texts(&continuation, "user").contains(&"old steer".to_string()));
|
||||
let follow_up: Value = from_slice(&requests[3]).expect("parse follow-up request");
|
||||
assert!(
|
||||
call_output_text(&follow_up, "custom_tool_call_output", EXEC_ID)
|
||||
.expect("exec output")
|
||||
.contains("Script running with cell ID ")
|
||||
);
|
||||
let steers = message_input_texts(&follow_up, "user")
|
||||
.into_iter()
|
||||
.filter(|text| text == "old steer")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(steers, vec!["old steer"]);
|
||||
.expect("turn should complete after the steer");
|
||||
|
||||
codex
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: held_tool.call_id,
|
||||
response: DynamicToolResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: "released".into(),
|
||||
}],
|
||||
success: true,
|
||||
},
|
||||
let requests = server.requests().await;
|
||||
let steer_counts = requests
|
||||
.iter()
|
||||
.skip(2)
|
||||
.map(|request| {
|
||||
let body: Value = from_slice(request).expect("parse follow-up request");
|
||||
message_input_texts(&body, "user")
|
||||
.into_iter()
|
||||
.filter(|text| text == "old steer")
|
||||
.count()
|
||||
})
|
||||
.await
|
||||
.expect("release nested tool");
|
||||
release_follow_up.send(()).expect("finish follow-up");
|
||||
wait_for_turn_complete(&codex).await;
|
||||
.collect::<Vec<_>>();
|
||||
assert!(steer_counts.contains(&1), "steer counts: {steer_counts:?}");
|
||||
assert!(steer_counts.iter().all(|count| *count <= 1));
|
||||
|
||||
codex
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
@@ -1621,32 +1648,11 @@ async fn steer_deferred_during_compaction_yields_code_mode() {
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
struct FirstExecYielded {
|
||||
sender: std::sync::Mutex<Option<oneshot::Sender<()>>>,
|
||||
}
|
||||
|
||||
impl codex_extension_api::ToolLifecycleContributor for FirstExecYielded {
|
||||
fn on_tool_finish<'a>(
|
||||
&'a self,
|
||||
input: codex_extension_api::ToolFinishInput<'a>,
|
||||
) -> codex_extension_api::ToolLifecycleFuture<'a> {
|
||||
Box::pin(async move {
|
||||
if input.tool_name.name == "exec"
|
||||
&& input.call_id == "first-exec"
|
||||
&& let Some(sender) = self.sender.lock().expect("lock sender").take()
|
||||
{
|
||||
let _ = sender.send(());
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn input_yields_a_code_mode_call_received_later_in_the_same_response() {
|
||||
let (first_yielded_tx, first_yielded_rx) = oneshot::channel();
|
||||
async fn input_preempts_response_and_yields_running_code_mode_call() {
|
||||
let (release_second_tx, release_second_rx) = oneshot::channel();
|
||||
let (release_final_tx, release_final_rx) = oneshot::channel();
|
||||
let (server, _) = start_streaming_sse_server(vec![
|
||||
let (server, mut completions) = start_streaming_sse_server(vec![
|
||||
vec![
|
||||
chunk(ev_response_created("resp-calls")),
|
||||
chunk(ev_custom_tool_call(
|
||||
@@ -1654,8 +1660,7 @@ async fn input_yields_a_code_mode_call_received_later_in_the_same_response() {
|
||||
"exec",
|
||||
"// @exec: {\"yield_time_ms\": 60000}\nawait tools.held_tool({}); text('first completed');",
|
||||
)),
|
||||
// This call is not even received until the first observation has
|
||||
// yielded, proving it sees the already-latched request signal.
|
||||
// The second call remains unavailable while the steer is processed.
|
||||
gated_chunk(
|
||||
release_second_rx,
|
||||
vec![
|
||||
@@ -1674,14 +1679,8 @@ async fn input_yields_a_code_mode_call_received_later_in_the_same_response() {
|
||||
],
|
||||
])
|
||||
.await;
|
||||
let mut extensions =
|
||||
codex_extension_api::ExtensionRegistryBuilder::<codex_core::config::Config>::new();
|
||||
extensions.tool_lifecycle_contributor(Arc::new(FirstExecYielded {
|
||||
sender: std::sync::Mutex::new(Some(first_yielded_tx)),
|
||||
}));
|
||||
let test = test_codex()
|
||||
.with_model("test-gpt-5.1-codex")
|
||||
.with_extensions(Arc::new(extensions.build()))
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
@@ -1728,30 +1727,26 @@ async fn input_yields_a_code_mode_call_received_later_in_the_same_response() {
|
||||
unreachable!("predicate guarantees a dynamic tool request");
|
||||
};
|
||||
steer_user_input(&codex, "yield cells").await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), first_yielded_rx)
|
||||
.await
|
||||
.expect("first exec should yield while the stream stays open")
|
||||
.expect("first exec completion observer");
|
||||
release_second_tx.send(()).expect("release second exec");
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
server.wait_for_request_count(/*count*/ 2),
|
||||
)
|
||||
.await
|
||||
.expect("both exec observations should yield before the follow-up");
|
||||
.expect("the first exec should yield before the follow-up");
|
||||
let requests = server.requests().await;
|
||||
let follow_up: Value = from_slice(&requests[1]).expect("parse follow-up request");
|
||||
let cell_id = |call_id| {
|
||||
let output = call_output_text(&follow_up, "custom_tool_call_output", call_id)
|
||||
.expect("yielded exec output");
|
||||
output
|
||||
.strip_prefix("Script running with cell ID ")
|
||||
.and_then(|rest| rest.lines().next())
|
||||
.expect("running cell ID")
|
||||
.to_string()
|
||||
};
|
||||
assert_ne!(cell_id("first-exec"), cell_id("second-exec"));
|
||||
assert!(
|
||||
call_output_text(&follow_up, "custom_tool_call_output", "first-exec")
|
||||
.expect("yielded exec output")
|
||||
.starts_with("Script running with cell ID ")
|
||||
);
|
||||
assert!(call_output_text(&follow_up, "custom_tool_call_output", "second-exec").is_none());
|
||||
assert!(message_input_texts(&follow_up, "user").contains(&"yield cells".to_string()));
|
||||
// Attempt to deliver the late call only after the replacement is running.
|
||||
let _ = release_second_tx.send(());
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(10), completions.remove(0))
|
||||
.await
|
||||
.expect("the old server response should finish or detect the closed client");
|
||||
|
||||
codex
|
||||
.submit(Op::DynamicToolResponse {
|
||||
@@ -1768,7 +1763,15 @@ async fn input_yields_a_code_mode_call_received_later_in_the_same_response() {
|
||||
release_final_tx
|
||||
.send(())
|
||||
.expect("finish follow-up response");
|
||||
wait_for_turn_complete(&codex).await;
|
||||
wait_for_event(&codex, |event| {
|
||||
assert!(!matches!(
|
||||
event,
|
||||
EventMsg::RawResponseItem(raw)
|
||||
if matches!(&raw.item, ResponseItem::CustomToolCall { call_id, .. } if call_id == "second-exec")
|
||||
));
|
||||
matches!(event, EventMsg::TurnComplete(completed) if completed.error.is_none())
|
||||
})
|
||||
.await;
|
||||
codex
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
|
||||
@@ -62,6 +62,8 @@ use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_custom_tool_call;
|
||||
use core_test_support::responses::ev_function_call_with_namespace;
|
||||
use core_test_support::responses::ev_message_item_added;
|
||||
use core_test_support::responses::ev_output_text_delta;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
@@ -998,6 +1000,126 @@ async fn astra_reads_code_mode_call_timing() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn astra_continues_after_a_stream_is_interrupted() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
// Keep the first response unfinished until after the replacement request.
|
||||
let (release_interrupted_response, interrupted_response_gate) = oneshot::channel();
|
||||
let mut commentary = ev_assistant_message("commentary", "I will draft the update.");
|
||||
commentary["item"]["phase"] = json!("commentary");
|
||||
let (streaming, _completions) = start_streaming_sse_server(vec![
|
||||
vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![
|
||||
ev_response_created("interrupted-response"),
|
||||
commentary,
|
||||
ev_message_item_added("unfinished-message", ""),
|
||||
ev_output_text_delta("The launch is "),
|
||||
]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(interrupted_response_gate),
|
||||
body: sse(vec![
|
||||
ev_assistant_message("unfinished-message", "The launch is tomorrow."),
|
||||
ev_completed("interrupted-response"),
|
||||
]),
|
||||
},
|
||||
],
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![
|
||||
ev_response_created("replacement-response"),
|
||||
ev_assistant_message("final", "The customer update is ready."),
|
||||
ev_completed("replacement-response"),
|
||||
]),
|
||||
}],
|
||||
])
|
||||
.await;
|
||||
let config_server = start_mock_server().await;
|
||||
let base_url = format!("{}/v1", streaming.uri());
|
||||
let test = test_codex()
|
||||
.with_model("gpt-6-astra")
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(move |config| {
|
||||
configure_scenario_catalog(config);
|
||||
config.model_provider.base_url = Some(base_url);
|
||||
config.model_provider.supports_websockets = false;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::InstantInterrupt)
|
||||
.expect("enable instant interrupt");
|
||||
config
|
||||
.features
|
||||
.disable(Feature::EnableRequestCompression)
|
||||
.expect("disable compression for the streaming test server");
|
||||
})
|
||||
.build_with_auto_env(&config_server)
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![text(
|
||||
"Draft a launch update.",
|
||||
)]))
|
||||
.await?;
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::AgentMessageContentDelta(delta)
|
||||
if delta.item_id == "unfinished-message" && delta.delta == "The launch is ")
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![text(
|
||||
"Make it a customer update.",
|
||||
)]))
|
||||
.await?;
|
||||
// A replacement request must arrive while the original stream is still held open.
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
streaming.wait_for_request_count(/*count*/ 2),
|
||||
)
|
||||
.await?;
|
||||
let completed = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::TurnComplete(completed) => Some(completed.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
completed.error.is_none(),
|
||||
"turn failed: {:?}",
|
||||
completed.error
|
||||
);
|
||||
|
||||
let requests = streaming
|
||||
.requests()
|
||||
.await
|
||||
.iter()
|
||||
.map(|body| serde_json::from_slice(body))
|
||||
.collect::<serde_json::Result<Vec<serde_json::Value>>>()?;
|
||||
assert_eq!(requests.len(), 2);
|
||||
let replacement = serde_json::to_string(&requests[1])?;
|
||||
assert!(replacement.contains("I will draft the update."));
|
||||
assert!(replacement.contains("Make it a customer update."));
|
||||
assert!(!replacement.contains("The launch is "));
|
||||
let entries = requests.iter().map(SnapshotEntry::body).collect::<Vec<_>>();
|
||||
let snapshot = context_snapshot::format_context_snapshot(
|
||||
"Astra receives new user input after its unfinished response is interrupted.",
|
||||
&entries,
|
||||
&ContextSnapshotOptions::default().rewrite_known_segments(),
|
||||
);
|
||||
insta::assert_snapshot!("astra_input_interrupts_response", snapshot);
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
drop(release_interrupted_response);
|
||||
streaming.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn astra_continues_after_input_yields_a_code_mode_cell() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: Astra receives new user input after its unfinished response is interrupted.
|
||||
|
||||
## Window 1
|
||||
-- request 1 (request) --
|
||||
00:additional_tools/developer (3; hash=32be5470c6e39932):
|
||||
- namespace/functions; hash=e7db0a45c4ec3157
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/request_user_input
|
||||
- function/request_user_input_async
|
||||
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
|
||||
- function/sleep
|
||||
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=fd994b11525da73c
|
||||
- function/followup_task
|
||||
- function/interrupt_agent
|
||||
- function/list_agents
|
||||
- function/send_message
|
||||
- function/spawn_agent
|
||||
- function/wait_agent
|
||||
01:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
02:message/developer[2]:
|
||||
[01] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <COLLABORATION_MODE>
|
||||
03:message/developer:
|
||||
<MULTI_AGENT_ROLE>
|
||||
04:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
05:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
06:message/user:
|
||||
Draft a launch update.
|
||||
-- request 2 (request) --
|
||||
07:message/assistant:
|
||||
I will draft the update.
|
||||
08:message/user:
|
||||
Make it a customer update.
|
||||
@@ -208,7 +208,7 @@ pub enum Feature {
|
||||
/// Keep sampling through reasoning and commentary boundaries when agent mail arrives.
|
||||
/// Pending mail is delivered at the next normal input boundary instead.
|
||||
DeferMailboxPreemption,
|
||||
/// Yield foreground code-mode observations when new user input arrives.
|
||||
/// Preempt responses and yield foreground code-mode observations on new user input.
|
||||
InstantInterrupt,
|
||||
/// Enable shared discussion tools for an agent tree.
|
||||
AgentMessageBoard,
|
||||
|
||||
Reference in New Issue
Block a user