Files
codex/codex-rs/core/tests/suite/pending_input.rs
T
pakrym-oai 12de0e395d Preserve WebSocket continuations when steering a turn (#48508)
## Why

Steering an active WebSocket response previously dropped the connection and
resent the full history. Draining the response preserves the connection and
allows the follow-up request to continue with `previous_response_id`.

## What changed

- Drain WebSocket responses when steering. For models using
  `use_responses_lite`, first send `response.interrupt` with
  `mode: "discard_partial_items"` once the response ID is available.
- Treat `response.incomplete` with reason `interrupted` as completion with
  `end_turn: false`, preserving token usage and allowing the turn to continue.
  Other incomplete reasons remain errors.

## Testing

Update the steering integration test to cover completed and discarded reasoning
items, asserting connection reuse, incremental follow-up input, and token usage
from the interrupted response.

GitOrigin-RevId: bc714b713e797cf1a67e3b26ffbf7664cb92eb33
2026-09-26 18:36:55 +00:00

2536 lines
90 KiB
Rust

use core_test_support::test_codex::local_selections;
use std::sync::Arc;
use codex_core::CodexThread;
use codex_core::StartIfIdleSubmission;
use codex_core::StartThreadOptions;
use codex_core::TurnInput;
use codex_core::TurnInputRequest;
use codex_core::TurnInputSubmission;
use codex_core::TurnStartOptions;
use codex_core::config::CurrentTimeReminderConfig;
use codex_extension_items::ExtensionItem;
use codex_extension_items::sleep::SleepItem;
use codex_features::Feature;
use codex_history::RolloutItem;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::items::TurnItem;
use codex_protocol::models::AgentMessageInputContent;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::turn_input::CyberAccessProgram;
use codex_protocol::user_input::UserInput;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::SnapshotEntry;
use core_test_support::responses;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_function_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_reasoning_item;
use core_test_support::responses::ev_reasoning_item_added;
use core_test_support::responses::ev_response_created;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::StreamingSseServer;
use core_test_support::streaming_sse::start_streaming_sse_server;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::from_slice;
use serde_json::json;
use test_case::test_case;
use tokio::sync::oneshot;
use wiremock::Mock;
use wiremock::Request;
use wiremock::matchers::method;
use wiremock::matchers::path_regex;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn idle_user_input_reaches_the_first_model_request() -> anyhow::Result<()> {
assert_idle_user_input_reaches_the_first_model_request(ModeKind::Default).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn idle_user_input_reaches_the_first_model_request_in_plan_mode() -> anyhow::Result<()> {
assert_idle_user_input_reaches_the_first_model_request(ModeKind::Plan).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn idle_response_items_include_pending_mailbox_in_first_request() -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&server,
responses::sse(vec![
ev_response_created("idle-response-items"),
ev_completed("idle-response-items"),
]),
)
.await;
let test = test_codex().build_with_auto_env(&server).await?;
submit_queue_only_agent_mail(test.codex.as_ref(), "pending mailbox input").await;
let submission = test
.codex
.start_turn_if_idle(TurnInputRequest::new(TurnInput::ResponseItem(
responses::user_message_item("automatic response item"),
)))
.await?;
let StartIfIdleSubmission::Started { turn_id } = submission else {
panic!("automatic input should start a turn");
};
wait_for_turn_complete(test.codex.as_ref()).await;
let request = response.single_request();
let request_body = request.body_json();
responses::assert_root_turn(&request_body, Some(&turn_id))?;
responses::assert_parent_turn(&request_body, /*expected*/ None)?;
let user_messages = request.message_input_texts("user");
assert!(
user_messages
.iter()
.any(|message| message == "automatic response item")
);
assert!(
request
.inputs_of_type("agent_message")
.iter()
.any(|message| {
message["author"] == "/root/worker"
&& message["recipient"] == "/root"
&& message["content"].as_array().is_some_and(|content| {
content.iter().any(|item| {
item["type"] == "input_text" && item["text"] == "pending mailbox input"
})
})
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standalone_tool_output_starts_instruction_turn() -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&server,
responses::sse(vec![ev_response_created("turn"), ev_completed("turn")]),
)
.await;
let test = test_codex().build_with_auto_env(&server).await?;
let expected_output = json!({
"type": "function_call_output",
"name": "send_message_to_thread",
"namespace": "codex_app",
"output": "delegated work",
});
let output = serde_json::from_value(expected_output.clone())?;
let submission = test
.codex
.start_or_steer_turn(TurnInputRequest::new(TurnInput::ResponseItem(output)))
.await?;
let TurnInputSubmission::Started { turn_id } = submission else {
panic!("standalone output should start a turn");
};
wait_for_turn_complete(test.codex.as_ref()).await;
let request = response.single_request();
responses::assert_root_turn(&request.body_json(), Some(&turn_id))?;
let output = &request.inputs_of_type("function_call_output")[0];
assert_eq!(output["name"], expected_output["name"]);
assert_eq!(output["namespace"], expected_output["namespace"]);
assert_eq!(output["output"], expected_output["output"]);
assert!(output.get("call_id").is_none());
Ok(())
}
async fn assert_idle_user_input_reaches_the_first_model_request(
mode: ModeKind,
) -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&server,
responses::sse(vec![
ev_response_created("idle-user-input"),
ev_completed("idle-user-input"),
]),
)
.await;
let test = test_codex().build_with_auto_env(&server).await?;
if mode == ModeKind::Plan {
core_test_support::submit_thread_settings(
test.codex.as_ref(),
ThreadSettingsOverrides {
collaboration_mode: Some(CollaborationMode {
mode,
settings: Settings {
model: test.session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
},
)
.await?;
}
let expected_input = vec![UserInput::Text {
text: "queued user input reaches the first request".to_string(),
text_elements: Vec::new(),
}];
let submission = test
.codex
.start_turn_if_idle(TurnInputRequest::new(TurnInput::UserInput {
content: expected_input.clone(),
client_id: Some("queued-user-message".to_string()),
}))
.await?;
assert!(matches!(submission, StartIfIdleSubmission::Started { .. }));
let user_message = core_test_support::wait_for_event_match(test.codex.as_ref(), |event| {
let EventMsg::ItemCompleted(event) = event else {
return None;
};
let TurnItem::UserMessage(item) = &event.item else {
return None;
};
Some(item.clone())
})
.await;
assert_eq!(
Some("queued-user-message".to_string()),
user_message.client_id
);
assert_eq!(expected_input, user_message.content);
wait_for_turn_complete(test.codex.as_ref()).await;
let request = response.single_request();
let request_body = request.body_json();
let turn_id = request_body["client_metadata"]["turn_id"]
.as_str()
.expect("idle user turn id");
responses::assert_root_turn(&request_body, Some(turn_id))?;
assert!(
request
.message_input_texts("user")
.iter()
.any(|text| text == "queued user input reaches the first request"),
"the first Responses request should contain the queued user message"
);
Ok(())
}
fn ev_message_item_done(id: &str, text: &str) -> Value {
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": id,
"content": [{"type": "output_text", "text": text}]
}
})
}
fn sse_event(event: Value) -> String {
responses::sse(vec![event])
}
fn message_input_texts(body: &Value, role: &str) -> Vec<String> {
body.get("input")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
.collect()
}
fn function_call_output_text<'a>(body: &'a Value, call_id: &str) -> Option<&'a str> {
body.get("input")
.and_then(Value::as_array)?
.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(call_id)
})?
.get("output")?
.as_str()
}
fn assert_interrupted_sleep_output(output: Option<&str>) {
let Some(output) = output else {
panic!("sleep output missing");
};
let Some(wall_time) = output
.strip_prefix("Wall time: ")
.and_then(|output| output.strip_suffix(" seconds\nSleep interrupted by new input."))
else {
panic!("sleep output should include wall time");
};
assert!(
wall_time.parse::<f64>().is_ok(),
"sleep wall time should be a number"
);
}
fn chunk(event: Value) -> StreamingSseChunk {
StreamingSseChunk {
gate: None,
body: responses::sse(vec![event]),
}
}
fn gated_chunk(gate: oneshot::Receiver<()>, events: Vec<Value>) -> StreamingSseChunk {
StreamingSseChunk {
gate: Some(gate),
body: responses::sse(events),
}
}
fn response_completed_chunks(response_id: &str) -> Vec<StreamingSseChunk> {
vec![
chunk(ev_response_created(response_id)),
chunk(ev_completed(response_id)),
]
}
async fn build_codex(server: &StreamingSseServer) -> Arc<CodexThread> {
test_codex()
.with_config(|config| config.update_plan_enabled = true)
.with_model("gpt-5.4")
.build_with_streaming_server(server)
.await
.expect("build streaming Codex test session")
.codex
}
async fn submit_user_input(codex: &CodexThread, text: &str) {
codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}]))
.await
.expect("submit user input");
}
async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) {
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_selections(test.config.cwd.clone())),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: test.session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
}),
)
.await
.expect("submit user turn");
}
async fn steer_user_input(codex: &CodexThread, text: &str) {
let submission = codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}]))
.await
.expect("steer user input");
assert!(matches!(submission, TurnInputSubmission::Steered { .. }));
}
async fn enqueue_queue_only_agent_mail(codex: &CodexThread, text: &str) {
codex
.submit(Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
AgentPath::try_from("/root/worker").expect("worker path should parse"),
AgentPath::root(),
Vec::new(),
text.to_string(),
/*trigger_turn*/ false,
),
start_options: Default::default(),
})
.await
.expect("submit queue-only agent mail");
}
async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) {
enqueue_queue_only_agent_mail(codex, text).await;
codex
.submit(Op::RealtimeConversationListVoices)
.await
.expect("submit list-voices barrier");
wait_for_event(codex, |event| {
matches!(event, EventMsg::RealtimeConversationListVoicesResponse(_))
})
.await;
}
async fn wait_for_reasoning_item_started(codex: &CodexThread) {
wait_for_event(codex, |event| {
matches!(
event,
EventMsg::ItemStarted(item_started)
if matches!(&item_started.item, TurnItem::Reasoning(_))
)
})
.await;
}
async fn wait_for_agent_message(codex: &CodexThread, text: &str) {
let final_message = wait_for_event(
codex,
|event| matches!(event, EventMsg::AgentMessage(message) if message.message == text),
)
.await;
assert!(matches!(final_message, EventMsg::AgentMessage(_)));
}
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!(
event,
EventMsg::ItemStarted(started)
if matches!(
&started.item,
TurnItem::Extension(ExtensionItem::Sleep(item)) if item.id == call_id
)
)
})
.await;
let EventMsg::ItemStarted(started) = event else {
unreachable!("wait predicate only accepts item/started events");
};
let TurnItem::Extension(ExtensionItem::Sleep(item)) = started.item else {
unreachable!("wait predicate only accepts sleep items");
};
assert_eq!(
item,
SleepItem {
id: call_id.to_string(),
duration_ms,
}
);
}
async fn wait_for_sleep_item_completed(codex: &CodexThread, call_id: &str, duration_ms: u64) {
let event = wait_for_event(codex, |event| {
matches!(
event,
EventMsg::ItemCompleted(completed)
if matches!(
&completed.item,
TurnItem::Extension(ExtensionItem::Sleep(item)) if item.id == call_id
)
)
})
.await;
let EventMsg::ItemCompleted(completed) = event else {
unreachable!("wait predicate only accepts item/completed events");
};
let TurnItem::Extension(ExtensionItem::Sleep(item)) = completed.item else {
unreachable!("wait predicate only accepts sleep items");
};
assert_eq!(
item,
SleepItem {
id: call_id.to_string(),
duration_ms,
}
);
}
struct SleepingRootExtension;
impl codex_extension_api::ThreadLifecycleContributor<codex_core::config::Config>
for SleepingRootExtension
{
fn on_thread_start<'a>(
&'a self,
input: codex_extension_api::ThreadStartInput<'a, codex_core::config::Config>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
input.thread_store.insert(SleepItem {
id: "clock-wait-1".to_string(),
duration_ms: 60_000,
});
})
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn queue_only_agent_mail_wakes_sleeping_root_with_previous_turn_context() {
const CHILD_MESSAGE: &str = "worker completed";
let server = responses::start_mock_server().await;
let requests = responses::mount_sse_sequence(
&server,
vec![
responses::sse_completed("resp-initial"),
responses::sse_completed("resp-wake"),
],
)
.await;
let mut extensions =
codex_extension_api::ExtensionRegistryBuilder::<codex_core::config::Config>::new();
extensions.thread_lifecycle_contributor(Arc::new(SleepingRootExtension));
let codex = test_codex()
.with_model("gpt-5.4")
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_extensions(Arc::new(extensions.build()))
.build_with_auto_env(&server)
.await
.expect("build Codex test session")
.codex;
codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "wait for the worker".to_string(),
text_elements: Vec::new(),
}])
.on_start(TurnStartOptions {
cyber_access_program: Some(CyberAccessProgram::Standard),
..Default::default()
}),
)
.await
.expect("start initial turn");
wait_for_turn_complete(&codex).await;
enqueue_queue_only_agent_mail(&codex, CHILD_MESSAGE).await;
wait_for_turn_complete(&codex).await;
assert_eq!(
requests
.requests()
.iter()
.map(|request| request.body_json()["access_programs"].clone())
.collect::<Vec<_>>(),
vec![json!({"cyber": "standard"}); 2],
);
let history = codex
.load_history(/*include_archived*/ true)
.await
.expect("load persisted thread history");
assert!(history.items.iter().any(|item| {
matches!(
item,
RolloutItem::ResponseItem(envelope)
if matches!(
&envelope.item,
codex_protocol::models::ResponseItem::AgentMessage { content, .. }
if content.iter().any(|content| matches!(
content,
codex_protocol::models::AgentMessageInputContent::InputText { text }
if text == CHILD_MESSAGE
))
)
)
}));
}
#[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";
const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration";
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_function_call_with_namespace(
WAIT_CALL_ID,
MULTI_AGENT_V2_NAMESPACE,
"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 = function_call_output_text(&second, WAIT_CALL_ID).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;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn any_new_input_interrupts_sleep() {
const FIRST_SLEEP_CALL_ID: &str = "sleep-call-1";
const SECOND_SLEEP_CALL_ID: &str = "sleep-call-2";
const SLEEP_DURATION_MS: u64 = 3_600_000;
const INITIAL_PROMPT: &str = "sleep for a while";
const STEER_PROMPT: &str = "stop sleeping and continue";
let sleep_arguments = json!({ "duration_ms": SLEEP_DURATION_MS }).to_string();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_function_call_with_namespace(
FIRST_SLEEP_CALL_ID,
"clock",
"sleep",
&sleep_arguments,
)),
chunk(ev_completed("resp-1")),
];
let second_chunks = vec![
chunk(ev_response_created("resp-2")),
chunk(ev_function_call_with_namespace(
SECOND_SLEEP_CALL_ID,
"clock",
"sleep",
&sleep_arguments,
)),
chunk(ev_completed("resp-2")),
];
let (server, _completions) = start_streaming_sse_server(vec![
first_chunks,
second_chunks,
response_completed_chunks("resp-3"),
])
.await;
let test = test_codex()
.with_model("gpt-5.4")
.with_config(|config| {
config
.features
.enable(Feature::CurrentTimeReminder)
.expect("test config should allow current-time reminders");
config.current_time_reminder = Some(CurrentTimeReminderConfig {
sleep_tool: true,
..CurrentTimeReminderConfig::default()
});
})
.build_with_streaming_server(&server)
.await
.expect("build Codex test session");
let codex = test.codex.clone();
submit_user_input(&codex, INITIAL_PROMPT).await;
wait_for_sleep_item_started(&codex, FIRST_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;
steer_user_input(&codex, STEER_PROMPT).await;
wait_for_sleep_item_completed(&codex, FIRST_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;
wait_for_sleep_item_started(&codex, SECOND_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;
// The list-voices barrier can consume the sleep completion that we need to observe next.
enqueue_queue_only_agent_mail(&codex, "new mailbox input").await;
wait_for_sleep_item_completed(&codex, SECOND_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 3);
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()]
);
assert_interrupted_sleep_output(function_call_output_text(&second, FIRST_SLEEP_CALL_ID));
let third: Value = from_slice(&requests[2]).expect("parse third request");
assert_interrupted_sleep_output(function_call_output_text(&third, SECOND_SLEEP_CALL_ID));
codex.submit(Op::Shutdown).await.expect("shutdown session");
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
let rollout_path = codex.rollout_path().expect("rollout path");
let rollout = tokio::fs::read_to_string(rollout_path)
.await
.expect("read rollout");
let persisted_sleep_items = rollout
.lines()
.filter_map(|line| codex_rollout::parse_rollout_line(line).ok())
.filter_map(|line| match line.item {
RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => match event.item {
TurnItem::Extension(ExtensionItem::Sleep(item)) => Some(item),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
persisted_sleep_items,
vec![
SleepItem {
id: FIRST_SLEEP_CALL_ID.to_string(),
duration_ms: SLEEP_DURATION_MS,
},
SleepItem {
id: SECOND_SLEEP_CALL_ID.to_string(),
duration_ms: SLEEP_DURATION_MS,
},
]
);
server.shutdown().await;
}
fn assert_two_responses_input_snapshot(snapshot_name: &str, requests: &[Vec<u8>]) {
assert_eq!(requests.len(), 2);
let options = ContextSnapshotOptions::default().rewrite_known_segments();
let first: Value = from_slice(&requests[0]).expect("parse first request");
let second: Value = from_slice(&requests[1]).expect("parse second request");
let snapshot = context_snapshot::format_context_snapshot(
"/responses POST bodies with pending input",
&[
SnapshotEntry::body(&first).labeled("First request"),
SnapshotEntry::body(&second).labeled("Second request"),
],
&options,
);
insta::assert_snapshot!(snapshot_name, snapshot);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "TODO(aibrahim): flaky"]
async fn injected_user_input_triggers_follow_up_request_with_deltas() {
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_message_item_added("msg-1", "")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_output_text_delta("first ")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_output_text_delta("turn")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_message_item_done("msg-1", "first turn")),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(ev_completed("resp-1")),
},
];
let second_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(ev_response_created("resp-2")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_completed("resp-2")),
},
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, second_chunks]).await;
let codex = test_codex()
.with_model("gpt-5.4")
.build_with_streaming_server(&server)
.await
.unwrap()
.codex;
codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "first prompt".into(),
text_elements: Vec::new(),
}]))
.await
.unwrap();
wait_for_event(&codex, |event| {
matches!(event, EventMsg::AgentMessageContentDelta(_))
})
.await;
codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "second prompt".into(),
text_elements: Vec::new(),
}]))
.await
.unwrap();
let _ = gate_completed_tx.send(());
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 2);
let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");
let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request");
let first_texts = message_input_texts(&first_body, "user");
assert!(first_texts.iter().any(|text| text == "first prompt"));
assert!(!first_texts.iter().any(|text| text == "second prompt"));
let second_texts = message_input_texts(&second_body, "user");
assert!(second_texts.iter().any(|text| text == "first prompt"));
assert!(second_texts.iter().any(|text| text == "second prompt"));
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn queued_inter_agent_mail_triggers_follow_up_after_reasoning_item() {
let (gate_reasoning_done_tx, gate_reasoning_done_rx) = oneshot::channel();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_reasoning_item_added("reason-1", &["thinking"])),
gated_chunk(
gate_reasoning_done_rx,
vec![
ev_reasoning_item("reason-1", &["thinking"], &[]),
ev_function_call(
"call-stale",
"shell",
r#"{"command":"echo stale tool call"}"#,
),
ev_message_item_added("msg-stale", ""),
ev_output_text_delta("stale final"),
ev_message_item_done("msg-stale", "stale final"),
ev_completed("resp-1"),
],
),
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
let codex = build_codex(&server).await;
submit_user_input(&codex, "first prompt").await;
wait_for_reasoning_item_started(&codex).await;
submit_queue_only_agent_mail(&codex, "queued child update").await;
let _ = gate_reasoning_done_tx.send(());
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_two_responses_input_snapshot("pending_input_queued_mail_after_reasoning", &requests);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn queued_inter_agent_mail_triggers_follow_up_after_commentary_message_item() {
let (gate_message_done_tx, gate_message_done_rx) = oneshot::channel();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_message_item_added("msg-1", "")),
gated_chunk(
gate_message_done_rx,
vec![
ev_output_text_delta("first answer"),
json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": "msg-1",
"content": [{"type": "output_text", "text": "first answer"}],
"phase": "commentary",
}
}),
ev_function_call(
"call-stale",
"shell",
r#"{"command":"echo stale tool call"}"#,
),
ev_message_item_added("msg-stale", ""),
ev_output_text_delta("stale final"),
ev_message_item_done("msg-stale", "stale final"),
ev_completed("resp-1"),
],
),
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
let codex = build_codex(&server).await;
submit_user_input(&codex, "first prompt").await;
wait_for_event(&codex, |event| {
matches!(
event,
EventMsg::ItemStarted(item_started)
if matches!(&item_started.item, TurnItem::AgentMessage(_))
)
})
.await;
submit_queue_only_agent_mail(&codex, "queued child update").await;
let _ = gate_message_done_tx.send(());
wait_for_agent_message(&codex, "first answer").await;
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_two_responses_input_snapshot("pending_input_queued_mail_after_commentary", &requests);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn queued_inter_agent_mail_does_not_restart_after_final_answer() {
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_message_item_added("msg-1", "")),
chunk(ev_output_text_delta("first answer")),
chunk(json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": "msg-1",
"content": [{"type": "output_text", "text": "first answer"}],
"phase": "final_answer",
}
})),
chunk(ev_completed("resp-1")),
];
let (server, _completions) = start_streaming_sse_server(vec![
first_chunks,
response_completed_chunks("unexpected-resp-2"),
])
.await;
let codex = build_codex(&server).await;
submit_queue_only_agent_mail(&codex, "queued child update").await;
submit_user_input(&codex, "first prompt").await;
wait_for_turn_complete(&codex).await;
let mut requests = server.requests().await;
assert_eq!(requests.len(), 1);
let request: Value = from_slice(&requests[0]).expect("parse request");
assert!(
request["input"]
.as_array()
.expect("request input")
.iter()
.all(|item| item.get("type").and_then(Value::as_str) != Some("agent_message"))
);
submit_user_input(&codex, "second prompt").await;
wait_for_turn_complete(&codex).await;
requests = server.requests().await;
assert_eq!(requests.len(), 2);
let request: Value = from_slice(&requests[1]).expect("parse request");
let input = request["input"].as_array().expect("request input");
let agent_message = input
.iter()
.find(|item| item.get("type").and_then(Value::as_str) == Some("agent_message"))
.expect("queued child update should be included in the next turn");
assert_eq!(
agent_message["content"],
json!([{"type": "input_text", "text": "queued child update"}])
);
let user_input = message_input_texts(&request, "user")
.into_iter()
.filter(|text| text == "second prompt")
.collect::<Vec<_>>();
assert_eq!(user_input, vec!["second prompt"]);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn injected_response_item_reopens_turn_after_final_answer() {
const INITIAL_PROMPT: &str = "first prompt";
const INJECTED_CONTEXT: &str = "late injected context";
const EXTERNAL_CONTEXT: &str = "external injected context";
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_message_item_added("msg-1", "")),
chunk(ev_output_text_delta("first answer")),
chunk(json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": "msg-1",
"content": [{"type": "output_text", "text": "first answer"}],
"phase": "final_answer",
}
})),
// Keep the response open past an observable event so the answer boundary is established
// before the late context is injected.
chunk(ev_reasoning_item_added("reason-after-final", &["done"])),
gated_chunk(
gate_completed_rx,
vec![
ev_reasoning_item("reason-after-final", &["done"], &[]),
ev_completed("resp-1"),
],
),
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
let codex = build_codex(&server).await;
submit_user_input(&codex, INITIAL_PROMPT).await;
wait_for_reasoning_item_started(&codex).await;
assert!(
codex
.inject_if_running(vec![responses::user_message_item(INJECTED_CONTEXT)])
.await
.is_ok()
);
codex
.inject_response_items(vec![responses::user_message_item(EXTERNAL_CONTEXT)])
.await
.expect("external context should be injected");
let _ = gate_completed_tx.send(());
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 2);
let first: Value = from_slice(&requests[0]).expect("parse first request");
let first_turn_id = first["client_metadata"]["turn_id"]
.as_str()
.expect("first request should include its turn ID");
responses::assert_root_turn(&first, Some(first_turn_id))
.expect("initial root should be trusted");
let second: Value = from_slice(&requests[1]).expect("parse second request");
responses::assert_root_turn(&second, Some(first_turn_id))
.expect("external injection should preserve the active turn root");
let relevant_user_input = message_input_texts(&second, "user")
.into_iter()
.filter(|text| {
text == INITIAL_PROMPT || text == INJECTED_CONTEXT || text == EXTERNAL_CONTEXT
})
.collect::<Vec<_>>();
assert_eq!(
relevant_user_input,
vec![INITIAL_PROMPT, INJECTED_CONTEXT, EXTERNAL_CONTEXT]
);
server.shutdown().await;
}
#[test_case(false; "completed_reasoning")]
#[test_case(true; "discarded_reasoning")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn steer_interrupts_and_drains_websocket(discard_partial: bool) -> anyhow::Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let mut interrupted = ev_completed_with_tokens("resp-interrupted", /*total_tokens*/ 42);
interrupted["type"] = json!("response.incomplete");
interrupted["response"]["status"] = json!("incomplete");
interrupted["response"]["incomplete_details"] = json!({"reason": "interrupted"});
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"]),
],
// Finish the active response only after receiving response.interrupt.
vec![
json!({
"type": "response.interrupt.accepted",
"response_id": "resp-interrupted",
"sequence_number": 3,
}),
if discard_partial {
json!({
"type": "response.output_item.interrupted",
"response_id": "resp-interrupted",
"item_id": "reason-1",
"output_index": 0,
"sequence_number": 4,
})
} else {
ev_reasoning_item("reason-1", &["thinking"], &[])
},
interrupted,
],
vec![
ev_response_created("resp-follow-up"),
ev_completed("resp-follow-up"),
],
]])
.await;
let test = test_codex()
.with_model("gpt-5.4")
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
})
.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*/ 0, /*request_index*/ 3),
)
.await
.expect("steer should interrupt, drain, and reuse the first socket")
.body_json();
assert_eq!(
server.single_connection()[2].body_json(),
json!({"type": "response.interrupt", "response_id": "resp-interrupted", "mode": "discard_partial_items"}),
);
// Incremental continuation is only possible when the drained output made it into history.
assert_eq!(follow_up["previous_response_id"], "resp-interrupted");
let prompts = message_input_texts(&follow_up, "user")
.into_iter()
.filter(|text| text == "first prompt" || text == "second prompt")
.collect::<Vec<_>>();
assert_eq!(prompts, vec!["second prompt"]);
wait_for_event(codex, |event| {
matches!(
event,
EventMsg::TokenCount(count)
if count.info.as_ref().is_some_and(|info| info.last_token_usage.total_tokens == 42)
)
})
.await;
wait_for_successful_turn(codex).await;
assert_eq!(server.connections().len(), 1);
codex.shutdown_and_wait().await?;
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";
let (release_response, response_gate) = oneshot::channel();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_function_call(TOOL_CALL_ID, "held_tool", "{}")),
gated_chunk(response_gate, vec![ev_completed("resp-1")]),
];
let (server, _) =
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
let test = test_codex()
.with_model("gpt-5.4")
.with_config(|config| {
config
.features
.enable(Feature::InstantInterrupt)
.expect("enable InstantInterrupt feature");
})
.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".to_string(),
description: "A tool held by the test until both steers are queued.".to_string(),
input_schema: json!({"type": "object", "properties": {}}),
defer_loading: false,
})],
..StartThreadOptions::new(test.config.clone())
})
.await
.expect("start thread with dynamic tool")
.thread;
submit_user_input(&codex, "first prompt").await;
let EventMsg::DynamicToolCallRequest(tool_request) = wait_for_event(&codex, |event| {
matches!(event, EventMsg::DynamicToolCallRequest(request) if request.call_id == TOOL_CALL_ID)
})
.await else {
unreachable!("predicate guarantees tool request");
};
steer_user_input(&codex, "second prompt").await;
steer_user_input(&codex, "third prompt").await;
codex
.submit(Op::DynamicToolResponse {
id: tool_request.call_id,
response: DynamicToolResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "held tool result".to_string(),
}],
success: true,
},
})
.await
.expect("complete dynamic tool");
wait_for_event(&codex, |event| {
assert!(!matches!(
event,
EventMsg::TurnAborted(_) | EventMsg::StreamError(_)
));
matches!(event, EventMsg::TurnComplete(completed) if completed.error.is_none())
})
.await;
let requests = server.requests().await;
assert_eq!(requests.len(), 2);
let second: Value = from_slice(&requests[1]).expect("parse replacement request");
let prompts = message_input_texts(&second, "user")
.into_iter()
.filter(|text| {
matches!(
text.as_str(),
"first prompt" | "second prompt" | "third prompt"
)
})
.collect::<Vec<_>>();
assert_eq!(
prompts,
vec!["first prompt", "second prompt", "third prompt"]
);
let result = function_call_output_text(&second, TOOL_CALL_ID).expect("real tool output");
assert!(result.contains("held tool result"), "tool output: {result}");
assert_eq!(
second["input"]
.as_array()
.expect("request input is an array")
.iter()
.filter(|item| item["type"] == "function_call_output" && item["call_id"] == TOOL_CALL_ID)
.count(),
1,
"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;
}
#[test_case(false; "disabled")]
#[test_case(true; "enabled")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn steers_yield_exec_and_wait_without_stopping_the_cell(instant_interrupt: bool) {
const EXEC_ID: &str = "exec-call";
const WAIT_ONE_ID: &str = "wait-one";
const WAIT_TWO_ID: &str = "wait-two";
let server = responses::start_mock_server().await;
let test = test_codex()
.with_model("test-gpt-5.1-codex")
.with_config(move |config| {
if instant_interrupt {
config
.features
.enable(Feature::InstantInterrupt)
.expect("enable InstantInterrupt feature");
} else {
config
.features
.disable(Feature::InstantInterrupt)
.expect("disable 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)
.expect("disable request compression");
config.model_provider.supports_websockets = false;
})
.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 {
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;
responses::mount_sse_once(
&server,
responses::sse(vec![
ev_response_created("resp-exec"),
ev_custom_tool_call(
EXEC_ID,
"exec",
"// @exec: {\"yield_time_ms\": 60000}\nawait tools.held_tool({}); text('background result');",
),
ev_completed("resp-exec"),
]),
)
.await;
if instant_interrupt {
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(
call_id,
"wait",
&json!({"cell_id": id, "yield_time_ms": 60000}).to_string(),
),
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;
submit_user_input(&codex, "start the cell").await;
let EventMsg::DynamicToolCallRequest(tool_request) = wait_for_event(&codex, |event| {
matches!(event, EventMsg::DynamicToolCallRequest(request) if request.tool == "held_tool")
})
.await else {
unreachable!("predicate guarantees tool request");
};
steer_user_input(&codex, "interrupt exec").await;
if !instant_interrupt {
codex
.submit(Op::DynamicToolResponse {
id: tool_request.call_id,
response: DynamicToolResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "released".into(),
}],
success: true,
},
})
.await
.expect("release nested tool");
wait_for_turn_complete(&codex).await;
let request = final_response.single_request().body_json();
assert!(
call_output_text(&request, "custom_tool_call_output", EXEC_ID)
.expect("exec output")
.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| {
matches!(event, EventMsg::RawResponseItem(raw) if matches!(&raw.item, ResponseItem::FunctionCall { call_id, .. } if call_id == WAIT_ONE_ID))
})
.await;
steer_user_input(&codex, "interrupt wait").await;
wait_for_event(&codex, |event| {
matches!(event, EventMsg::RawResponseItem(raw) if matches!(&raw.item, ResponseItem::FunctionCall { call_id, .. } if call_id == WAIT_TWO_ID))
})
.await;
codex
.submit(Op::DynamicToolResponse {
id: tool_request.call_id,
response: DynamicToolResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "released".into(),
}],
success: true,
},
})
.await
.expect("release nested tool");
wait_for_event(&codex, |event| {
assert!(!matches!(
event,
EventMsg::TurnAborted(_) | EventMsg::StreamError(_)
));
matches!(event, EventMsg::TurnComplete(completed) if completed.error.is_none())
})
.await;
let request = final_response.single_request().body_json();
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!(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");
assert!(
completed_output.contains("background result"),
"{completed_output}"
);
let prompts = message_input_texts(&request, "user")
.into_iter()
.filter(|text| {
matches!(
text.as_str(),
"start the cell" | "interrupt exec" | "interrupt wait"
)
})
.collect::<Vec<_>>();
assert_eq!(
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_during_compaction_is_sent_after_compaction() {
let (release_compact, compact_gate) = oneshot::channel();
let (server, _) = start_streaming_sse_server(vec![
vec![
chunk(ev_response_created("resp-first")),
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")),
gated_chunk(
compact_gate,
vec![
ev_message_item_done("msg-compact", "AUTO_COMPACT_SUMMARY"),
ev_completed_with_tokens("resp-compact", /*total_tokens*/ 50),
],
),
],
response_completed_chunks("resp-follow-up"),
response_completed_chunks("resp-extra-compaction"),
response_completed_chunks("resp-steered"),
])
.await;
let test = test_codex()
.with_model("gpt-5.4")
.with_config(|config| {
config.model_provider.name = "OpenAI (test)".into();
config.model_provider.supports_websockets = false;
config.model_auto_compact_token_limit = Some(200);
config
.features
.enable(Feature::InstantInterrupt)
.expect("enable InstantInterrupt feature");
config
.features
.disable(Feature::EnableRequestCompression)
.expect("disable request compression");
})
.build_with_streaming_server(&server)
.await
.expect("build Codex test session");
let codex = test.codex.clone();
submit_user_input(&codex, "first prompt").await;
tokio::time::timeout(
std::time::Duration::from_secs(10),
server.wait_for_request_count(/*count*/ 2),
)
.await
.expect("compaction request should begin");
steer_user_input(&codex, "old steer").await;
release_compact.send(()).expect("finish compaction");
tokio::time::timeout(
std::time::Duration::from_secs(10),
wait_for_successful_turn(&codex),
)
.await
.expect("turn should complete after the steer");
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()
})
.collect::<Vec<_>>();
assert!(steer_counts.contains(&1), "steer counts: {steer_counts:?}");
assert!(steer_counts.iter().all(|count| *count <= 1));
codex
.shutdown_and_wait()
.await
.expect("shut down test session");
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
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, mut completions) = start_streaming_sse_server(vec![
vec![
chunk(ev_response_created("resp-calls")),
chunk(ev_custom_tool_call(
"first-exec",
"exec",
"// @exec: {\"yield_time_ms\": 60000}\nawait tools.held_tool({}); text('first completed');",
)),
// The second call remains unavailable while the steer is processed.
gated_chunk(
release_second_rx,
vec![
ev_custom_tool_call(
"second-exec",
"exec",
"// @exec: {\"yield_time_ms\": 60000}\nawait new Promise(() => {});",
),
ev_completed("resp-calls"),
],
),
],
vec![
chunk(ev_response_created("resp-follow-up")),
gated_chunk(release_final_rx, vec![ev_completed("resp-follow-up")]),
],
])
.await;
let test = test_codex()
.with_model("test-gpt-5.1-codex")
.with_config(|config| {
config
.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)
.expect("disable request compression");
config.model_provider.supports_websockets = false;
})
.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;
submit_user_input(&codex, "start cells").await;
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");
};
steer_user_input(&codex, "yield cells").await;
tokio::time::timeout(
std::time::Duration::from_secs(10),
server.wait_for_request_count(/*count*/ 2),
)
.await
.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");
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 {
id: held_tool.call_id,
response: DynamicToolResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "released".into(),
}],
success: true,
},
})
.await
.expect("release nested tool");
release_final_tx
.send(())
.expect("finish follow-up response");
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
.expect("shut down test session");
server.shutdown().await;
}
fn call_output_text(body: &Value, output_type: &str, call_id: &str) -> Option<String> {
let output = &body["input"]
.as_array()?
.iter()
.find(|item| item["type"] == output_type && item["call_id"] == call_id)?["output"];
match output {
Value::String(text) => Some(text.clone()),
Value::Array(items) => Some(
items
.iter()
.filter_map(|item| item["text"].as_str())
.collect::<Vec<_>>()
.join("\n"),
),
_ => None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_input_does_not_preempt_after_reasoning_item() {
let (gate_reasoning_done_tx, gate_reasoning_done_rx) = oneshot::channel();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_reasoning_item_added("reason-1", &["thinking"])),
gated_chunk(
gate_reasoning_done_rx,
vec![
ev_reasoning_item("reason-1", &["thinking"], &[]),
ev_function_call(
"call-preserved",
"shell",
r#"{"command":"echo preserved tool call"}"#,
),
ev_message_item_added("msg-1", ""),
ev_output_text_delta("first answer"),
ev_message_item_done("msg-1", "first answer"),
ev_completed("resp-1"),
],
),
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
let codex = build_codex(&server).await;
submit_user_input(&codex, "first prompt").await;
wait_for_reasoning_item_started(&codex).await;
steer_user_input(&codex, "second prompt").await;
let _ = gate_reasoning_done_tx.send(());
wait_for_agent_message(&codex, "first answer").await;
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_two_responses_input_snapshot(
"pending_input_user_input_no_preempt_after_reasoning",
&requests,
);
server.shutdown().await;
}
#[derive(Clone, Copy)]
enum ConditionalInterruptCase {
CurrentTurn,
StaleTurn,
PendingUserInput,
PendingMailbox,
AbandonedRequest,
}
#[test_case(ConditionalInterruptCase::CurrentTurn; "current_turn_without_pending_input")]
#[test_case(ConditionalInterruptCase::StaleTurn; "stale_turn")]
#[test_case(ConditionalInterruptCase::PendingUserInput; "pending_user_input")]
#[test_case(ConditionalInterruptCase::PendingMailbox; "pending_mailbox")]
#[test_case(ConditionalInterruptCase::AbandonedRequest; "abandoned_request")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn interrupt_if_no_pending_input_checks_turn_and_queue(
case: ConditionalInterruptCase,
) -> anyhow::Result<()> {
const INITIAL_PROMPT: &str = "first prompt";
const PENDING_PROMPT: &str = "preserve this pending input";
let (release_response, response_gate) = oneshot::channel();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_reasoning_item_added("reason-1", &["thinking"])),
gated_chunk(
response_gate,
vec![
ev_reasoning_item("reason-1", &["thinking"], &[]),
ev_completed("resp-1"),
],
),
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
let config_server = responses::start_mock_server().await;
let base_url = format!("{}/v1", server.uri());
let test = test_codex()
.with_model("gpt-5.4")
.with_config(move |config| {
config.model_provider.base_url = Some(base_url);
let _ = config.features.disable(Feature::EnableRequestCompression);
})
.build_with_auto_env(&config_server)
.await?;
let codex = &test.codex;
let TurnInputSubmission::Started { turn_id } = codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: INITIAL_PROMPT.to_string(),
text_elements: Vec::new(),
}]))
.await?
else {
panic!("initial input should start a turn");
};
wait_for_reasoning_item_started(codex).await;
if matches!(case, ConditionalInterruptCase::PendingUserInput) {
steer_user_input(codex, PENDING_PROMPT).await;
}
if matches!(case, ConditionalInterruptCase::PendingMailbox) {
submit_queue_only_agent_mail(codex, PENDING_PROMPT).await;
}
let expected_turn_id = match case {
ConditionalInterruptCase::StaleTurn | ConditionalInterruptCase::AbandonedRequest => {
format!("stale-{turn_id}")
}
ConditionalInterruptCase::CurrentTurn
| ConditionalInterruptCase::PendingUserInput
| ConditionalInterruptCase::PendingMailbox => turn_id.clone(),
};
if matches!(case, ConditionalInterruptCase::AbandonedRequest) {
let (reply, result) = oneshot::channel();
drop(result);
codex
.submit(Op::InterruptIfNoPendingInput { turn_id, reply })
.await?;
// Wait for the abandoned request to be handled before releasing the response.
let (reply, result) = oneshot::channel();
codex
.submit(Op::InterruptIfNoPendingInput {
turn_id: expected_turn_id.clone(),
reply,
})
.await?;
assert!(!tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), result).await??);
}
let should_abort = matches!(case, ConditionalInterruptCase::CurrentTurn);
assert_eq!(
tokio::time::timeout(
std::time::Duration::from_secs(/*secs*/ 10),
codex.interrupt_if_no_pending_input(&expected_turn_id),
)
.await??,
should_abort,
);
if should_abort {
wait_for_event(codex, |event| {
assert!(!matches!(event, EventMsg::TurnComplete(_)));
matches!(event, EventMsg::TurnAborted(_))
})
.await;
let _ = release_response.send(());
} else {
release_response.send(()).expect("release model response");
wait_for_event(codex, |event| {
assert!(!matches!(event, EventMsg::TurnAborted(_)));
matches!(event, EventMsg::TurnComplete(_))
})
.await;
}
let requests = server.requests().await;
if matches!(case, ConditionalInterruptCase::PendingUserInput) {
assert_eq!(requests.len(), 2);
let second: Value = from_slice(&requests[1])?;
let prompts = message_input_texts(&second, "user")
.into_iter()
.filter(|text| text == INITIAL_PROMPT || text == PENDING_PROMPT)
.collect::<Vec<_>>();
assert_eq!(prompts, vec![INITIAL_PROMPT, PENDING_PROMPT]);
} else if matches!(case, ConditionalInterruptCase::PendingMailbox) {
assert_eq!(requests.len(), 2);
let second: Value = from_slice(&requests[1])?;
let mail = second["input"]
.as_array()
.expect("model input")
.iter()
.find(|item| item["type"] == "agent_message")
.expect("pending mailbox input");
assert_eq!(
mail["content"],
json!([{ "type": "input_text", "text": PENDING_PROMPT }])
);
} else {
assert_eq!(requests.len(), 1);
}
server.shutdown().await;
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CompactionFailurePoint {
PreTurn,
MidTurn,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PendingInputAfterFailure {
Steer,
QueuedMail,
TriggeringMail,
}
#[test_case(CompactionFailurePoint::PreTurn, PendingInputAfterFailure::Steer; "pre_turn_steer")]
#[test_case(CompactionFailurePoint::PreTurn, PendingInputAfterFailure::QueuedMail; "pre_turn_mail")]
#[test_case(CompactionFailurePoint::PreTurn, PendingInputAfterFailure::TriggeringMail; "pre_turn_triggering_mail")]
#[test_case(CompactionFailurePoint::MidTurn, PendingInputAfterFailure::Steer; "mid_turn_steer")]
#[test_case(CompactionFailurePoint::MidTurn, PendingInputAfterFailure::QueuedMail; "mid_turn_mail")]
#[test_case(CompactionFailurePoint::MidTurn, PendingInputAfterFailure::TriggeringMail; "mid_turn_triggering_mail")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn terminal_compaction_error_does_not_retry_pending_input(
failure_point: CompactionFailurePoint,
pending_input: PendingInputAfterFailure,
) -> anyhow::Result<()> {
const PENDING_MESSAGE: &str = "pending input must survive the failed compaction";
let (release_failure, failure_gate) = oneshot::channel();
let initial_output = match failure_point {
CompactionFailurePoint::PreTurn => ev_message_item_done("initial", "first answer"),
CompactionFailurePoint::MidTurn => ev_function_call("call-1", "test_tool", "{}"),
};
let failure = responses::sse_failed("failed-compact", "insufficient_quota", "quota exhausted");
let mut streams = vec![
vec![
chunk(ev_response_created("initial")),
chunk(initial_output),
chunk(ev_completed_with_tokens(
"initial", /*total_tokens*/ 500_000,
)),
],
vec![StreamingSseChunk {
gate: Some(failure_gate),
body: failure.clone(),
}],
];
// Mail arriving during a failed turn may start one fresh turn. That turn must also
// stop on the terminal error, persist its mail, and not start another turn for it.
let failed_turns = if pending_input == PendingInputAfterFailure::TriggeringMail {
streams.push(vec![StreamingSseChunk {
gate: None,
body: failure,
}]);
2
} else {
1
};
streams.extend([
vec![
chunk(json!({
"type": "response.output_item.done",
"item": {
"type": "compaction",
"encrypted_content": "RECOVERED_COMPACTION",
}
})),
chunk(ev_completed_with_tokens(
"recovered-compact",
/*total_tokens*/ 50,
)),
],
vec![
chunk(ev_message_item_done("recovered", "recovered answer")),
chunk(ev_completed_with_tokens(
"recovered",
/*total_tokens*/ 60,
)),
],
]);
let (server, _completions) = start_streaming_sse_server(streams).await;
let config_server = responses::start_mock_server().await;
let base_url = format!("{}/v1", server.uri());
let test = test_codex()
.with_model("gpt-5.4")
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.model_provider.base_url = Some(base_url);
config.model_auto_compact_token_limit = Some(100_000);
// The streaming fixture records raw request bodies for JSON assertions.
let _ = config.features.disable(Feature::EnableRequestCompression);
})
.build_with_auto_env(&config_server)
.await?;
let codex = &test.codex;
if failure_point == CompactionFailurePoint::PreTurn {
submit_user_input(codex, "initial prompt").await;
wait_for_turn_complete(codex).await;
}
if pending_input == PendingInputAfterFailure::QueuedMail {
submit_queue_only_agent_mail(codex, PENDING_MESSAGE).await;
}
submit_user_input(codex, "prompt that needs compaction").await;
tokio::time::timeout(
std::time::Duration::from_secs(/*secs*/ 10),
server.wait_for_request_count(/*count*/ 2),
)
.await?;
match pending_input {
PendingInputAfterFailure::Steer => steer_user_input(codex, PENDING_MESSAGE).await,
PendingInputAfterFailure::QueuedMail => {}
PendingInputAfterFailure::TriggeringMail => {
codex
.submit(Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
AgentPath::root().join("worker").expect("valid worker path"),
AgentPath::root(),
Vec::new(),
PENDING_MESSAGE.to_string(),
/*trigger_turn*/ true,
),
start_options: Default::default(),
})
.await?;
codex.submit(Op::RealtimeConversationListVoices).await?;
wait_for_event(codex, |event| {
matches!(event, EventMsg::RealtimeConversationListVoicesResponse(_))
})
.await;
}
}
release_failure.send(()).expect("release compact failure");
let mut errors = Vec::new();
let mut completed_turns = 0;
wait_for_event(codex, |event| {
match event {
EventMsg::Error(error) => {
assert_eq!(
error.codex_error_info,
Some(CodexErrorInfo::UsageLimitExceeded)
);
errors.push(error.clone());
}
EventMsg::TurnComplete(completed) => {
assert_eq!(completed.error.as_ref(), errors.last());
assert_eq!(completed.last_agent_message, None);
completed_turns += 1;
}
_ => {}
}
completed_turns == failed_turns
})
.await;
assert_eq!(errors.len(), failed_turns);
let requests = server.requests().await;
assert_eq!(requests.len(), 1 + failed_turns);
for request in &requests[1..] {
let body: Value = from_slice(request)?;
assert!(
body["input"]
.as_array()
.expect("compact input")
.iter()
.any(|item| { item["type"] == "compaction_trigger" })
);
}
codex.flush_rollout().await?;
let history = codex.load_history(/*include_archived*/ false).await?;
let saved_pending_messages = history
.items
.iter()
.filter_map(|item| {
let RolloutItem::ResponseItem(envelope) = item else {
return None;
};
match &envelope.item {
ResponseItem::Message { role, content, .. } if role == "user" => {
content.iter().find_map(|item| match item {
ContentItem::InputText { text } if text == PENDING_MESSAGE => {
Some(text.as_str())
}
_ => None,
})
}
ResponseItem::AgentMessage { content, .. } => {
content.iter().find_map(|item| match item {
AgentMessageInputContent::InputText { text } if text == PENDING_MESSAGE => {
Some(text.as_str())
}
_ => None,
})
}
_ => None,
}
})
.collect::<Vec<_>>();
assert_eq!(saved_pending_messages, vec![PENDING_MESSAGE]);
// The failed turn must not poison a later explicit retry once compaction can succeed.
submit_user_input(codex, "retry after quota resets").await;
wait_for_agent_message(codex, "recovered answer").await;
let completed = wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let EventMsg::TurnComplete(completed) = completed else {
unreachable!("expected turn completion");
};
assert_eq!(completed.error, None);
assert_eq!(server.requests().await.len(), 3 + failed_turns);
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn steered_user_input_waits_for_model_continuation_after_mid_turn_compact() {
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_function_call("call-1", "test_tool", "{}")),
chunk(ev_completed_with_tokens(
"resp-1", /*total_tokens*/ 500,
)),
];
let compact_chunks = vec![
chunk(ev_response_created("resp-compact")),
chunk(ev_message_item_done("msg-compact", "AUTO_COMPACT_SUMMARY")),
chunk(ev_completed_with_tokens(
"resp-compact",
/*total_tokens*/ 50,
)),
];
let post_compact_continuation_chunks = vec![
chunk(ev_response_created("resp-post-compact")),
chunk(ev_message_item_added("msg-post-compact", "")),
chunk(ev_output_text_delta("resumed old task")),
chunk(ev_message_item_done("msg-post-compact", "resumed old task")),
chunk(ev_completed_with_tokens(
"resp-post-compact",
/*total_tokens*/ 60,
)),
];
let steered_follow_up_chunks = vec![
chunk(ev_response_created("resp-steered")),
chunk(ev_message_item_done(
"msg-steered",
"processed steered prompt",
)),
chunk(ev_completed_with_tokens(
"resp-steered",
/*total_tokens*/ 70,
)),
];
let (server, _completions) = start_streaming_sse_server(vec![
first_chunks,
compact_chunks,
post_compact_continuation_chunks,
steered_follow_up_chunks,
])
.await;
let codex = test_codex()
.with_model("gpt-5.4")
.with_config(|config| {
config.model_provider.name = "OpenAI (test)".to_string();
config.model_provider.supports_websockets = false;
config.model_auto_compact_token_limit = Some(200);
})
.build_with_streaming_server(&server)
.await
.expect("build streaming Codex test session")
.codex;
submit_user_input(&codex, "first prompt").await;
submit_user_input(&codex, "second prompt").await;
wait_for_agent_message(&codex, "resumed old task").await;
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 4);
let post_compact_body: Value = from_slice(&requests[2]).expect("parse post-compact request");
let steered_body: Value = from_slice(&requests[3]).expect("parse steered request");
let post_compact_user_texts = message_input_texts(&post_compact_body, "user");
assert!(
!post_compact_user_texts
.iter()
.any(|text| text == "second prompt"),
"steered input should stay pending until the model resumes after compaction"
);
let steered_user_texts = message_input_texts(&steered_body, "user");
assert!(
steered_user_texts
.iter()
.any(|text| text == "second prompt"),
"steered input should be recorded on the request after the post-compact continuation"
);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn steered_user_input_follows_compact_when_only_the_steer_needs_follow_up() {
let (gate_first_completed_tx, gate_first_completed_rx) = oneshot::channel();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_message_item_added("msg-1", "")),
chunk(ev_output_text_delta("first answer")),
chunk(ev_message_item_done("msg-1", "first answer")),
gated_chunk(
gate_first_completed_rx,
vec![ev_completed_with_tokens(
"resp-1", /*total_tokens*/ 500,
)],
),
];
let compact_chunks = vec![
chunk(ev_response_created("resp-compact")),
chunk(ev_message_item_done("msg-compact", "AUTO_COMPACT_SUMMARY")),
chunk(ev_completed_with_tokens(
"resp-compact",
/*total_tokens*/ 50,
)),
];
let steered_follow_up_chunks = vec![
chunk(ev_response_created("resp-steered")),
chunk(ev_message_item_done(
"msg-steered",
"processed steered prompt",
)),
chunk(ev_completed_with_tokens(
"resp-steered",
/*total_tokens*/ 70,
)),
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, compact_chunks, steered_follow_up_chunks])
.await;
let codex = test_codex()
.with_model("gpt-5.4")
.with_config(|config| {
config.model_provider.name = "OpenAI (test)".to_string();
config.model_provider.supports_websockets = false;
config.model_auto_compact_token_limit = Some(200);
})
.build_with_streaming_server(&server)
.await
.expect("build streaming Codex test session")
.codex;
submit_user_input(&codex, "first prompt").await;
wait_for_agent_message(&codex, "first answer").await;
steer_user_input(&codex, "second prompt").await;
let _ = gate_first_completed_tx.send(());
wait_for_agent_message(&codex, "processed steered prompt").await;
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 3);
let compact_body: Value = from_slice(&requests[1]).expect("parse compact request");
let steered_body: Value = from_slice(&requests[2]).expect("parse steered request");
let compact_user_texts = message_input_texts(&compact_body, "user");
assert!(
!compact_user_texts
.iter()
.any(|text| text == "second prompt"),
"steered input should not be included in the compaction request"
);
let steered_user_texts = message_input_texts(&steered_body, "user");
assert!(
steered_user_texts
.iter()
.any(|text| text == "second prompt"),
"steered input should follow compaction without an empty resume request when the model was already done"
);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn steered_user_input_waits_when_tool_output_triggers_compact_before_next_request() {
let (gate_first_completed_tx, gate_first_completed_rx) = oneshot::channel();
let large_output_command = if cfg!(windows) {
"[Console]::Out.Write([string]::new([char]'0', 4000))"
} else {
"printf '%04000d' 0"
};
let large_output_args = json!({
"cmd": large_output_command,
"login": false,
"yield_time_ms": 2000,
})
.to_string();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_function_call(
"call-1",
"exec_command",
&large_output_args,
)),
gated_chunk(
gate_first_completed_rx,
vec![ev_completed_with_tokens(
"resp-1", /*total_tokens*/ 100,
)],
),
];
let compact_chunks = vec![
chunk(ev_response_created("resp-compact")),
chunk(ev_message_item_done("msg-compact", "TOOL_OUTPUT_SUMMARY")),
chunk(ev_completed_with_tokens(
"resp-compact",
/*total_tokens*/ 50,
)),
];
let post_compact_continuation_chunks = vec![
chunk(ev_response_created("resp-post-compact")),
chunk(ev_message_item_done(
"msg-post-compact",
"resumed after compacting tool output",
)),
chunk(ev_completed_with_tokens(
"resp-post-compact",
/*total_tokens*/ 60,
)),
];
let steered_follow_up_chunks = vec![
chunk(ev_response_created("resp-steered")),
chunk(ev_message_item_done(
"msg-steered",
"processed steered prompt",
)),
chunk(ev_completed_with_tokens(
"resp-steered",
/*total_tokens*/ 70,
)),
];
let (server, _completions) = start_streaming_sse_server(vec![
first_chunks,
compact_chunks,
post_compact_continuation_chunks,
steered_follow_up_chunks,
])
.await;
let test = test_codex()
.with_model("gpt-5.4")
.with_config(|config| {
config.model_provider.name = "OpenAI (test)".to_string();
config.model_provider.supports_websockets = false;
config.model_auto_compact_token_limit = Some(200);
})
.build_with_streaming_server(&server)
.await
.expect("build streaming Codex test session");
let codex = test.codex.clone();
submit_danger_full_access_user_turn(&test, "first prompt").await;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnStarted(_))).await;
steer_user_input(&codex, "second prompt").await;
let _ = gate_first_completed_tx.send(());
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 4);
let compact_body: Value = from_slice(&requests[1]).expect("parse compact request");
let post_compact_body: Value = from_slice(&requests[2]).expect("parse post-compact request");
let steered_body: Value = from_slice(&requests[3]).expect("parse steered request");
let compact_user_texts = message_input_texts(&compact_body, "user");
assert!(
!compact_user_texts
.iter()
.any(|text| text == "second prompt"),
"steered input should not be included in the compaction request"
);
let post_compact_user_texts = message_input_texts(&post_compact_body, "user");
assert!(
!post_compact_user_texts
.iter()
.any(|text| text == "second prompt"),
"steered input should stay pending until after the compacted continuation"
);
let steered_user_texts = message_input_texts(&steered_body, "user");
assert!(
steered_user_texts
.iter()
.any(|text| text == "second prompt"),
"steered input should be recorded on the request after the post-compact continuation"
);
server.shutdown().await;
}